├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── cmd └── godown │ └── main.go ├── go.mod ├── go.sum ├── godown.go ├── godown_test.go └── testdata ├── test001.html ├── test001.md ├── test002.html ├── test002.md ├── test003.html ├── test003.md ├── test004.html ├── test004.md ├── test005.html ├── test005.md ├── test006.html ├── test006.md ├── test007.html ├── test007.md ├── test008.html ├── test008.md ├── test009.html ├── test009.md ├── test010.html ├── test010.md ├── test011.html ├── test011.md ├── test012.html ├── test012.md ├── test013.html ├── test013.md ├── test014.html ├── test014.md ├── test015.html ├── test015.md ├── test016.html ├── test016.md ├── test017.html ├── test017.md ├── test018.html ├── test018.md ├── test019.html ├── test019.md ├── test020.html ├── test020.md ├── test021.html ├── test021.md ├── test022.html ├── test022.md ├── test023.html ├── test023.md ├── test024.html ├── test024.md ├── test025.html └── test025.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | godown 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.8 5 | - 1.9.3 6 | 7 | env: 8 | secure: "sssm68fvzltcAnFd0Vc+OJV0eOicaTUO4I/CRX9LsnqzSsiNmaFT5o1O0Mx622ApYxCfiG3G53B8wuPzSMQxdPaSiMqzoXNLjD8gURaZBWTXc+kj9WFUoS4KW5L8KF3zrmS1u6Ja9U/elIpNqbpuwqT7sZUUJJM1JR50uEVmtP9oc/iqTKh3JK1HZCkb/PDVKs7xY5AEOhx1x0QOn9SegMUK2b83WeuSbta3Z6Rp4EW3p3WwI1WHZmm8+IYjvbwu18foQSetfro+pXCyDBpw1zLbBTDR8W02VwkH2vECMm4N7GYPmHWNx2lZFqoFp9zY5zCRUQG9KmxqbappalBCsT1ZyesUt7Wp/qYw5W+1Np7/vQhe8eeyyKMzsS7FBq8Imn4JiBPbj/1KAhVoZZKyv0qU4hgxHPZMC/JtVoTkIH3IXvTE88P92z9pFL30afQ692BXPe+XmCBph4zBdH88vksdiky9DWuXJ+O0rDCcLes45ij/wk6psdTPx3IXuMohfkO81F0pveksBYFkff8dXXxCABUzZbPaawEDnLAQKJ1m+oF3UYhPzVwrelNjFDOUq3mxsTU36uyhB9fb8sJ+BmorTD9AvqNvobcwKlQ6TaVJEhHjDJRxho82OG2gof9UbsJF3+6IM8uuUVy3TP7b1o+t8PQ3iNKTB4RUzGjJCOs=" 9 | 10 | script: 11 | go test -v -race -coverprofile=coverage.txt -covermode=atomic 12 | 13 | after_success: 14 | - bash <(curl -s https://codecov.io/bash) 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Yasuhiro Matsumoto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # godown 2 | 3 | [![Build Status](https://travis-ci.org/mattn/godown.png?branch=master)](https://travis-ci.org/mattn/godown) 4 | [![Codecov](https://codecov.io/gh/mattn/godown/branch/master/graph/badge.svg)](https://codecov.io/gh/mattn/godown) 5 | [![GoDoc](https://godoc.org/github.com/mattn/godown?status.svg)](http://godoc.org/github.com/mattn/godown) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/mattn/godown)](https://goreportcard.com/report/github.com/mattn/godown) 7 | 8 | Convert HTML into Markdown 9 | 10 | This is work in progress. 11 | 12 | ## Usage 13 | 14 | ``` 15 | err := godown.Convert(w, r) 16 | checkError(err) 17 | ``` 18 | 19 | 20 | ## Command Line 21 | 22 | ``` 23 | $ godown < index.html > index.md 24 | ``` 25 | 26 | ## Installation 27 | 28 | ``` 29 | $ go get github.com/mattn/godown/cmd/godown 30 | ``` 31 | 32 | ## TODO 33 | 34 | * escape strings in HTML 35 | 36 | ## License 37 | 38 | MIT 39 | 40 | ## Author 41 | 42 | Yasuhiro Matsumoto (a.k.a. mattn) 43 | -------------------------------------------------------------------------------- /cmd/godown/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "runtime" 9 | "strings" 10 | 11 | "github.com/mattn/godown" 12 | ) 13 | 14 | var ( 15 | guesslang = flag.String("g", "", "guesslang") 16 | option *godown.Option 17 | ) 18 | 19 | func guesslanger(code string) (string, error) { 20 | var cmd *exec.Cmd 21 | if runtime.GOOS == "windows" { 22 | cmd = exec.Command("cmd", "/c", *guesslang) 23 | } else { 24 | cmd = exec.Command("sh", "-c", *guesslang) 25 | } 26 | cmd.Stdin = strings.NewReader(code) 27 | b, err := cmd.CombinedOutput() 28 | return strings.ToLower(strings.TrimSpace(string(b))), err 29 | } 30 | 31 | func main() { 32 | flag.Parse() 33 | if *guesslang != "" { 34 | option = &godown.Option{GuessLang: guesslanger} 35 | } 36 | option := &godown.Option{GuessLang: guesslanger} 37 | if err := godown.Convert(os.Stdout, os.Stdin, option); err != nil { 38 | log.Fatal(err) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mattn/godown 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/mattn/go-runewidth v0.0.8 7 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/mattn/go-runewidth v0.0.8 h1:3tS41NlGYSmhhe/8fhGRzc+z3AYCw1Fe1WAyLuujKs0= 2 | github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 3 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 4 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI= 5 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 6 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 7 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 8 | -------------------------------------------------------------------------------- /godown.go: -------------------------------------------------------------------------------- 1 | package godown 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "regexp" 8 | "strings" 9 | "unicode" 10 | 11 | "github.com/mattn/go-runewidth" 12 | 13 | "golang.org/x/net/html" 14 | ) 15 | 16 | // A regex to escape certain characters 17 | // \ : since this is the excape character, become weird if printed literally 18 | // * : Used to start bullet lists, and as a delimiter 19 | // _ : Used as a delimiter 20 | // ( and ) : Used in links and images 21 | // [ and ] : Used in links and images 22 | // < : can be used to mean "raw HTML" which is allowed 23 | // > : Used in raw HTML, also used to define blockquotes 24 | // # : Used for headings 25 | // + : Can be used for unordered lists 26 | // - : Can be used for unordered lists 27 | // ! : Used for images 28 | // ` : Used for code blocks 29 | var escapeRegex = regexp.MustCompile(`(` + `\\|\*|_|\[|\]|\(|\)|<|>|#|\+|-|!|` + "`" + `)`) 30 | 31 | func isChildOf(node *html.Node, name string) bool { 32 | node = node.Parent 33 | return node != nil && node.Type == html.ElementNode && strings.ToLower(node.Data) == name 34 | } 35 | 36 | func hasClass(node *html.Node, clazz string) bool { 37 | for _, attr := range node.Attr { 38 | if attr.Key == "class" { 39 | for _, c := range strings.Fields(attr.Val) { 40 | if c == clazz { 41 | return true 42 | } 43 | } 44 | } 45 | } 46 | return false 47 | } 48 | 49 | func attr(node *html.Node, key string) string { 50 | for _, attr := range node.Attr { 51 | if attr.Key == key { 52 | return attr.Val 53 | } 54 | } 55 | return "" 56 | } 57 | 58 | // Gets the language of a code block based on the class 59 | // See: https://spec.commonmark.org/0.29/#example-112 60 | func langFromClass(node *html.Node) string { 61 | if node.FirstChild == nil || strings.ToLower(node.FirstChild.Data) != "code" { 62 | return "" 63 | } 64 | 65 | fChild := node.FirstChild 66 | classes := strings.Fields(attr(fChild, "class")) 67 | if len(classes) == 0 { 68 | return "" 69 | } 70 | 71 | prefix := "language-" 72 | for _, class := range classes { 73 | if !strings.HasPrefix(class, prefix) { 74 | continue 75 | } 76 | return strings.TrimPrefix(class, prefix) 77 | } 78 | 79 | return "" 80 | } 81 | 82 | func br(node *html.Node, w io.Writer, option *Option) { 83 | node = node.PrevSibling 84 | if node == nil { 85 | return 86 | } 87 | 88 | // If trimspace is set to true, new lines will be ignored in nodes 89 | // so we force a new line when using br() 90 | if option.TrimSpace { 91 | fmt.Fprint(w, "\n") 92 | return 93 | } 94 | 95 | switch node.Type { 96 | case html.TextNode: 97 | text := strings.Trim(node.Data, " \t") 98 | if text != "" && !strings.HasSuffix(text, "\n") { 99 | fmt.Fprint(w, "\n") 100 | } 101 | case html.ElementNode: 102 | switch strings.ToLower(node.Data) { 103 | case "br", "p", "ul", "ol", "div", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6": 104 | fmt.Fprint(w, "\n") 105 | } 106 | } 107 | } 108 | 109 | func table(node *html.Node, w io.Writer, option *Option) { 110 | var list []*html.Node // create a list not to mess up the loop 111 | 112 | for tsection := node.FirstChild; tsection != nil; tsection = tsection.NextSibling { 113 | // if the thead/tbody/tfoot is not explicitly set, it is implicitly set as tbody 114 | if tsection.Type == html.ElementNode { 115 | switch strings.ToLower(tsection.Data) { 116 | case "thead", "tbody", "tfoot": 117 | for tr := tsection.FirstChild; tr != nil; tr = tr.NextSibling { 118 | if strings.TrimSpace(tr.Data) == "" { 119 | continue 120 | } 121 | list = append(list, tr) 122 | } 123 | } 124 | } 125 | } 126 | 127 | // Now we create a new node, add all the to the node and convert it 128 | newTableNode := new(html.Node) 129 | for _, n := range list { 130 | n.Parent.RemoveChild(n) 131 | newTableNode.AppendChild(n) 132 | } 133 | 134 | tableRows(newTableNode, w, option) 135 | fmt.Fprint(w, "\n") 136 | } 137 | 138 | func tableRows(node *html.Node, w io.Writer, option *Option) { 139 | var rows [][]string 140 | for tr := node.FirstChild; tr != nil; tr = tr.NextSibling { 141 | if tr.Type != html.ElementNode || strings.ToLower(tr.Data) != "tr" { 142 | continue 143 | } 144 | var cols []string 145 | for td := tr.FirstChild; td != nil; td = td.NextSibling { 146 | nodeType := strings.ToLower(td.Data) 147 | if td.Type != html.ElementNode || (nodeType != "td" && nodeType != "th") { 148 | continue 149 | } 150 | var buf bytes.Buffer 151 | walk(td, &buf, 0, option) 152 | cols = append(cols, buf.String()) 153 | } 154 | rows = append(rows, cols) 155 | } 156 | 157 | maxcol := 0 158 | for _, cols := range rows { 159 | if len(cols) > maxcol { 160 | maxcol = len(cols) 161 | } 162 | } 163 | widths := make([]int, maxcol) 164 | for _, cols := range rows { 165 | for i := 0; i < maxcol; i++ { 166 | if i < len(cols) { 167 | width := runewidth.StringWidth(cols[i]) 168 | if widths[i] < width { 169 | widths[i] = width 170 | } 171 | } 172 | } 173 | } 174 | for i, cols := range rows { 175 | for j := 0; j < maxcol; j++ { 176 | fmt.Fprint(w, "|") 177 | if j < len(cols) { 178 | width := runewidth.StringWidth(cols[j]) 179 | fmt.Fprint(w, cols[j]) 180 | fmt.Fprint(w, strings.Repeat(" ", widths[j]-width)) 181 | } else { 182 | fmt.Fprint(w, strings.Repeat(" ", widths[j])) 183 | } 184 | } 185 | fmt.Fprint(w, "|\n") 186 | if i == 0 { 187 | for j := 0; j < maxcol; j++ { 188 | fmt.Fprint(w, "|") 189 | fmt.Fprint(w, strings.Repeat("-", widths[j])) 190 | } 191 | fmt.Fprint(w, "|\n") 192 | } 193 | } 194 | } 195 | 196 | var emptyElements = []string{ 197 | "area", 198 | "base", 199 | "br", 200 | "col", 201 | "embed", 202 | "hr", 203 | "img", 204 | "input", 205 | "keygen", 206 | "link", 207 | "meta", 208 | "param", 209 | "source", 210 | "track", 211 | "wbr", 212 | } 213 | 214 | func raw(node *html.Node, w io.Writer, option *Option) { 215 | html.Render(w, node) 216 | } 217 | 218 | func bq(node *html.Node, w io.Writer, option *Option) { 219 | if node.Type == html.TextNode { 220 | fmt.Fprint(w, strings.Replace(node.Data, "\u00a0", " ", -1)) 221 | } else { 222 | for c := node.FirstChild; c != nil; c = c.NextSibling { 223 | bq(c, w, option) 224 | } 225 | } 226 | } 227 | 228 | func pre(node *html.Node, w io.Writer, option *Option) { 229 | if node.Type == html.TextNode { 230 | fmt.Fprint(w, node.Data) 231 | } else { 232 | for c := node.FirstChild; c != nil; c = c.NextSibling { 233 | pre(c, w, option) 234 | } 235 | } 236 | } 237 | 238 | // In the spec, https://spec.commonmark.org/0.29/#delimiter-run 239 | // A left-flanking delimiter run should not followed by Unicode whitespace 240 | // A right-flanking delimiter run should not preceded by Unicode whitespace 241 | // This will wrap the delimiter (such as **) around the non-whitespace contents, but preserve the whitespace 242 | func aroundNonWhitespace(node *html.Node, w io.Writer, nest int, option *Option, before, after string) { 243 | buf := &bytes.Buffer{} 244 | 245 | walk(node, buf, nest, option) 246 | s := buf.String() 247 | 248 | // If the contents are simply whitespace, return without adding any delimiters 249 | if strings.TrimSpace(s) == "" { 250 | fmt.Fprint(w, s) 251 | return 252 | } 253 | 254 | start := 0 255 | for ; start < len(s); start++ { 256 | c := s[start] 257 | if !unicode.IsSpace(rune(c)) { 258 | break 259 | } 260 | } 261 | 262 | stop := len(s) 263 | for ; stop > start; stop-- { 264 | c := s[stop-1] 265 | if !unicode.IsSpace(rune(c)) { 266 | break 267 | } 268 | } 269 | 270 | s = s[:start] + before + s[start:stop] + after + s[stop:] 271 | 272 | fmt.Fprint(w, s) 273 | } 274 | 275 | func walk(node *html.Node, w io.Writer, nest int, option *Option) { 276 | if node.Type == html.TextNode { 277 | if option.TrimSpace && strings.TrimSpace(node.Data) == "" { 278 | return 279 | } 280 | 281 | text := regexp.MustCompile(`[[:space:]][[:space:]]*`).ReplaceAllString(strings.Trim(node.Data, "\t\r\n"), " ") 282 | 283 | if !option.doNotEscape { 284 | text = escapeRegex.ReplaceAllStringFunc(text, func(str string) string { 285 | return `\` + str 286 | }) 287 | } 288 | fmt.Fprint(w, text) 289 | } 290 | 291 | italicChar := "_" 292 | if option.ItalicsAsterix { 293 | italicChar = "*" 294 | } 295 | 296 | n := 0 297 | for c := node.FirstChild; c != nil; c = c.NextSibling { 298 | switch c.Type { 299 | case html.CommentNode: 300 | if option.IgnoreComments { 301 | break 302 | } 303 | fmt.Fprint(w, "\n") 306 | case html.ElementNode: 307 | customWalk, ok := option.customRulesMap[strings.ToLower(c.Data)] 308 | if ok { 309 | customWalk(c, w, nest, option) 310 | break 311 | } 312 | 313 | switch strings.ToLower(c.Data) { 314 | case "a": 315 | // Links are invalid in markdown if the link text extends beyond a single line 316 | // So we render the contents and strip any spaces 317 | href := attr(c, "href") 318 | end := fmt.Sprintf("](%s)", href) 319 | title := attr(c, "title") 320 | if title != "" { 321 | end = fmt.Sprintf("](%s %q)", href, title) 322 | } 323 | aroundNonWhitespace(c, w, nest, option, "[", end) 324 | case "b", "strong": 325 | aroundNonWhitespace(c, w, nest, option, "**", "**") 326 | case "i", "em": 327 | aroundNonWhitespace(c, w, nest, option, italicChar, italicChar) 328 | case "del", "s": 329 | aroundNonWhitespace(c, w, nest, option, "~~", "~~") 330 | case "br": 331 | br(c, w, option) 332 | fmt.Fprint(w, "\n\n") 333 | case "p": 334 | br(c, w, option) 335 | walk(c, w, nest, option) 336 | br(c, w, option) 337 | fmt.Fprint(w, "\n\n") 338 | case "code": 339 | if !isChildOf(c, "pre") { 340 | fmt.Fprint(w, "`") 341 | pre(c, w, option) 342 | fmt.Fprint(w, "`") 343 | } 344 | case "pre": 345 | br(c, w, option) 346 | 347 | clone := option.Clone() 348 | clone.doNotEscape = true 349 | 350 | var buf bytes.Buffer 351 | pre(c, &buf, clone) 352 | inner := buf.String() 353 | 354 | var lang string = langFromClass(c) 355 | if option != nil && option.GuessLang != nil { 356 | if guess, err := option.GuessLang(buf.String()); err == nil { 357 | lang = guess 358 | } 359 | } 360 | 361 | fmt.Fprint(w, "```"+lang+"\n") 362 | fmt.Fprint(w, inner) 363 | if !strings.HasSuffix(inner, "\n") { 364 | fmt.Fprint(w, "\n") 365 | } 366 | fmt.Fprint(w, "```\n\n") 367 | case "div": 368 | br(c, w, option) 369 | walk(c, w, nest, option) 370 | fmt.Fprint(w, "\n") 371 | case "blockquote": 372 | br(c, w, option) 373 | var buf bytes.Buffer 374 | if hasClass(c, "code") { 375 | bq(c, &buf, option) 376 | var lang string 377 | if option != nil && option.GuessLang != nil { 378 | if guess, err := option.GuessLang(buf.String()); err == nil { 379 | lang = guess 380 | } 381 | } 382 | fmt.Fprint(w, "```"+lang+"\n") 383 | fmt.Fprint(w, strings.TrimLeft(buf.String(), "\n")) 384 | if !strings.HasSuffix(buf.String(), "\n") { 385 | fmt.Fprint(w, "\n") 386 | } 387 | fmt.Fprint(w, "```\n\n") 388 | } else { 389 | walk(c, &buf, nest+1, option) 390 | 391 | if lines := strings.Split(strings.TrimSpace(buf.String()), "\n"); len(lines) > 0 { 392 | for _, l := range lines { 393 | fmt.Fprint(w, "> "+strings.TrimSpace(l)+"\n") 394 | } 395 | fmt.Fprint(w, "\n") 396 | } 397 | } 398 | case "ul", "ol": 399 | br(c, w, option) 400 | 401 | newOption := option.Clone() 402 | newOption.TrimSpace = true 403 | 404 | var buf bytes.Buffer 405 | walk(c, &buf, nest+1, newOption) 406 | 407 | // Remove any empty lines in the list 408 | if lines := strings.Split(buf.String(), "\n"); len(lines) > 0 { 409 | for i, l := range lines { 410 | if strings.TrimSpace(l) == "" { 411 | continue 412 | } 413 | 414 | if i > 0 { 415 | fmt.Fprint(w, "\n") 416 | } 417 | 418 | fmt.Fprint(w, l) 419 | } 420 | fmt.Fprint(w, "\n") 421 | if nest == 0 { 422 | fmt.Fprint(w, "\n") 423 | } 424 | } 425 | case "li": 426 | br(c, w, option) 427 | 428 | var buf bytes.Buffer 429 | walk(c, &buf, 0, option) 430 | 431 | markPrinted := false 432 | 433 | for _, l := range strings.Split(buf.String(), "\n") { 434 | if strings.TrimSpace(l) == "" { 435 | continue 436 | } 437 | // if markPrinted { 438 | 439 | // } 440 | if markPrinted { 441 | fmt.Fprint(w, "\n ") 442 | } 443 | 444 | fmt.Fprint(w, strings.Repeat(" ", nest-1)) 445 | 446 | if !markPrinted { 447 | if isChildOf(c, "ul") { 448 | fmt.Fprint(w, "* ") 449 | } else if isChildOf(c, "ol") { 450 | n++ 451 | fmt.Fprint(w, fmt.Sprintf("%d. ", n)) 452 | } 453 | 454 | markPrinted = true 455 | } 456 | 457 | fmt.Fprint(w, l) 458 | } 459 | 460 | fmt.Fprint(w, "\n") 461 | 462 | case "h1", "h2", "h3", "h4", "h5", "h6": 463 | br(c, w, option) 464 | fmt.Fprint(w, strings.Repeat("#", int(rune(c.Data[1])-rune('0')))+" ") 465 | walk(c, w, nest, option) 466 | fmt.Fprint(w, "\n\n") 467 | case "img": 468 | src := attr(c, "src") 469 | alt := attr(c, "alt") 470 | title := attr(c, "title") 471 | 472 | if src == "" { 473 | break 474 | } 475 | 476 | full := fmt.Sprintf("![%s](%s)", alt, src) 477 | if title != "" { 478 | full = fmt.Sprintf("![%s](%s %q)", alt, src, title) 479 | } 480 | 481 | fmt.Fprint(w, full) 482 | case "hr": 483 | br(c, w, option) 484 | fmt.Fprint(w, "\n---\n\n") 485 | case "table": 486 | br(c, w, option) 487 | table(c, w, option) 488 | case "style": 489 | if option != nil && option.Style { 490 | br(c, w, option) 491 | raw(c, w, option) 492 | fmt.Fprint(w, "\n\n") 493 | } 494 | case "script": 495 | if option != nil && option.Script { 496 | br(c, w, option) 497 | raw(c, w, option) 498 | fmt.Fprint(w, "\n\n") 499 | } 500 | default: 501 | walk(c, w, nest, option) 502 | } 503 | default: 504 | walk(c, w, nest, option) 505 | } 506 | } 507 | } 508 | 509 | // WalkFunc type is an signature for functions traversing HTML nodes 510 | type WalkFunc func(node *html.Node, w io.Writer, nest int, option *Option) 511 | 512 | // CustomRule is an interface to define custom conversion rules 513 | // 514 | // Rule method accepts `next WalkFunc` as an argument, which `customRule` should call 515 | // to let walk function continue parsing the content inside the HTML tag. 516 | // It returns a tagName to indicate what HTML element this `customRule` handles and the `customRule` 517 | // function itself, where conversion logic should reside. 518 | // 519 | // See example TestRule implementation in godown_test.go 520 | type CustomRule interface { 521 | Rule(next WalkFunc) (tagName string, customRule WalkFunc) 522 | } 523 | 524 | // Option is optional information for Convert. 525 | type Option struct { 526 | GuessLang func(string) (string, error) 527 | Script bool 528 | Style bool 529 | TrimSpace bool 530 | CustomRules []CustomRule 531 | IgnoreComments bool 532 | ItalicsAsterix bool // Used to know if to use _ or * for italics 533 | doNotEscape bool // Used to know if to escape certain characters 534 | customRulesMap map[string]WalkFunc 535 | } 536 | 537 | // To make a copy of an option without changing the original 538 | func (o *Option) Clone() *Option { 539 | if o == nil { 540 | return nil 541 | } 542 | 543 | clone := *o 544 | return &clone 545 | } 546 | 547 | // Convert convert HTML to Markdown. Read HTML from r and write to w. 548 | func Convert(w io.Writer, r io.Reader, option *Option) error { 549 | doc, err := html.Parse(r) 550 | if err != nil { 551 | return err 552 | } 553 | if option == nil { 554 | option = &Option{} 555 | } 556 | 557 | option.customRulesMap = make(map[string]WalkFunc) 558 | for _, cr := range option.CustomRules { 559 | tag, customWalk := cr.Rule(walk) 560 | option.customRulesMap[tag] = customWalk 561 | } 562 | 563 | walk(doc, w, 0, option) 564 | fmt.Fprint(w, "\n") 565 | return nil 566 | } 567 | -------------------------------------------------------------------------------- /godown_test.go: -------------------------------------------------------------------------------- 1 | package godown 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "sort" 11 | "strings" 12 | "testing" 13 | 14 | "golang.org/x/net/html" 15 | ) 16 | 17 | func TestGodown(t *testing.T) { 18 | m, err := filepath.Glob("testdata/*.html") 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | sort.Strings(m) 23 | for _, file := range m { 24 | f, err := os.Open(file) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | var buf bytes.Buffer 29 | if err = Convert(&buf, f, nil); err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | b, err := ioutil.ReadFile(file[:len(file)-4] + "md") 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | if string(b) != buf.String() { 38 | t.Errorf("(%s):\nwant:\n%s}}}\ngot:\n%s}}}\n", file, string(b), buf.String()) 39 | } 40 | f.Close() 41 | } 42 | } 43 | 44 | type errReader int 45 | 46 | func (e errReader) Read(p []byte) (n int, err error) { 47 | return 0, io.ErrUnexpectedEOF 48 | } 49 | 50 | func TestError(t *testing.T) { 51 | var buf bytes.Buffer 52 | var e errReader 53 | err := Convert(&buf, e, nil) 54 | if err == nil { 55 | t.Fatal("should be an error") 56 | } 57 | } 58 | 59 | func TestGuessLang(t *testing.T) { 60 | var buf bytes.Buffer 61 | err := Convert(&buf, strings.NewReader(` 62 |
 63 | def do_something():
 64 |   pass
 65 | 
66 | `), &Option{ 67 | GuessLang: func(s string) (string, error) { return "python", nil }, 68 | }) 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | want := "```python\ndef do_something():\n pass\n```\n\n\n" 73 | if buf.String() != want { 74 | t.Errorf("\nwant:\n%s}}}\ngot:\n%s}}}\n", want, buf.String()) 75 | } 76 | } 77 | 78 | func TestGuessLangFromClass(t *testing.T) { 79 | var buf bytes.Buffer 80 | err := Convert(&buf, strings.NewReader(` 81 |
def do_something():
 82 |   pass
 83 | 
84 | `), nil) 85 | if err != nil { 86 | t.Fatal(err) 87 | } 88 | want := "```python\ndef do_something():\n pass\n```\n\n\n" 89 | if buf.String() != want { 90 | t.Errorf("\nwant:\n%s}}}\ngot:\n%s}}}\n", want, buf.String()) 91 | } 92 | } 93 | 94 | func TestGuessLangBq(t *testing.T) { 95 | var buf bytes.Buffer 96 | err := Convert(&buf, strings.NewReader(` 97 |
98 | def do_something(): 99 | pass 100 |
101 | `), &Option{ 102 | GuessLang: func(s string) (string, error) { return "python", nil }, 103 | }) 104 | if err != nil { 105 | t.Fatal(err) 106 | } 107 | want := "```python\ndef do_something():\n pass\n```\n\n\n" 108 | if buf.String() != want { 109 | t.Errorf("\nwant:\n%s}}}\ngot:\n%s}}}\n", want, buf.String()) 110 | } 111 | } 112 | 113 | func TestWhiteSpaceDelimiter(t *testing.T) { 114 | // Test adding delimiters only on the inner contents 115 | var buf bytes.Buffer 116 | err := Convert(&buf, strings.NewReader( 117 | ` foo bar `, 118 | ), nil) 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | want := " **foo bar** \n" 123 | if buf.String() != want { 124 | t.Errorf("\nwant:\n%q}}}\ngot:\n%q}}}\n", want, buf.String()) 125 | } 126 | 127 | // Test that no delimiters are added if the contents is all whitespace 128 | var buf2 bytes.Buffer 129 | err = Convert(&buf2, strings.NewReader( 130 | `Hello hi`, 131 | ), nil) 132 | if err != nil { 133 | t.Fatal(err) 134 | } 135 | want = "Hello hi\n" 136 | if buf2.String() != want { 137 | t.Errorf("\nwant:\n%q}}}\ngot:\n%q}}}\n", want, buf2.String()) 138 | } 139 | 140 | // Test that line breaks are preserved even if delimiters are not added 141 | var buf3 bytes.Buffer 142 | err = Convert(&buf3, strings.NewReader( 143 | `
`, 144 | ), nil) 145 | if err != nil { 146 | t.Fatal(err) 147 | } 148 | want = "\n\n\n" 149 | if buf3.String() != want { 150 | t.Errorf("\nwant:\n%q}}}\ngot:\n%q}}}\n", want, buf3.String()) 151 | } 152 | } 153 | 154 | func TestEmptyImageSrc(t *testing.T) { 155 | var buf bytes.Buffer 156 | err := Convert(&buf, strings.NewReader( 157 | `foo bar`, 158 | ), nil) 159 | if err != nil { 160 | t.Fatal(err) 161 | } 162 | want := "\n" 163 | if buf.String() != want { 164 | t.Errorf("\nwant:\n%q}}}\ngot:\n%q}}}\n", want, buf.String()) 165 | } 166 | } 167 | 168 | func TestBlockLink(t *testing.T) { 169 | var buf bytes.Buffer 170 | err := Convert(&buf, strings.NewReader( 171 | `foo bar
`, 172 | ), nil) 173 | if err != nil { 174 | t.Fatal(err) 175 | } 176 | want := "[![foo bar](https://example.com/img)](https://example.org)\n\n" 177 | if buf.String() != want { 178 | t.Errorf("\nwant:\n%q}}}\ngot:\n%q}}}\n", want, buf.String()) 179 | } 180 | } 181 | 182 | func TestScript(t *testing.T) { 183 | var buf bytes.Buffer 184 | err := Convert(&buf, strings.NewReader(` 185 |

here is script

186 | 187 | 188 | 189 | 192 | `), &Option{ 193 | Script: true, 194 | }) 195 | if err != nil { 196 | t.Fatal(err) 197 | } 198 | want := `here is script 199 | 200 | 201 | 202 | 205 | 206 | 207 | ` 208 | if buf.String() != want { 209 | t.Errorf("\nwant:\n%s}}}\ngot:\n%s}}}\n", want, buf.String()) 210 | } 211 | } 212 | 213 | func TestStyle(t *testing.T) { 214 | var buf bytes.Buffer 215 | err := Convert(&buf, strings.NewReader(` 216 |

here is style

217 | 218 | 223 | `), &Option{ 224 | Style: true, 225 | }) 226 | if err != nil { 227 | t.Fatal(err) 228 | } 229 | want := `here is style 230 | 231 | 236 | 237 | 238 | ` 239 | if buf.String() != want { 240 | t.Errorf("\nwant:\n%s}}}\ngot:\n%s}}}\n", want, buf.String()) 241 | } 242 | } 243 | 244 | type TestRule struct{} 245 | 246 | func (r *TestRule) Rule(next WalkFunc) (string, WalkFunc) { 247 | return "test", func(node *html.Node, w io.Writer, nest int, option *Option) { 248 | fmt.Fprint(w, "_") 249 | next(node, w, nest, option) 250 | fmt.Fprint(w, "_") 251 | } 252 | } 253 | 254 | func TestCustomRules(t *testing.T) { 255 | var buf bytes.Buffer 256 | err := Convert(&buf, strings.NewReader(` 257 | here is the text in custom tag 258 | `), &Option{ 259 | CustomRules: []CustomRule{&TestRule{}}, 260 | }) 261 | if err != nil { 262 | t.Fatal(err) 263 | } 264 | want := `_here is the text in custom tag_ 265 | ` 266 | if buf.String() != want { 267 | t.Errorf("\nwant:\n%s}}}\ngot:\n%s}}}\n", want, buf.String()) 268 | } 269 | } 270 | 271 | type TestOverwriteRule struct{} 272 | 273 | func (r *TestOverwriteRule) Rule(next WalkFunc) (string, WalkFunc) { 274 | return "div", func(node *html.Node, w io.Writer, nest int, option *Option) { 275 | fmt.Fprint(w, "___") 276 | next(node, w, nest, option) 277 | fmt.Fprint(w, "___") 278 | } 279 | } 280 | 281 | func TestCustomOverwriteRules(t *testing.T) { 282 | var buf bytes.Buffer 283 | err := Convert(&buf, strings.NewReader(` 284 |
here is the text in custom tag
285 | `), &Option{ 286 | CustomRules: []CustomRule{&TestOverwriteRule{}}, 287 | }) 288 | if err != nil { 289 | t.Fatal(err) 290 | } 291 | want := `___here is the text in custom tag___ 292 | ` 293 | if buf.String() != want { 294 | t.Errorf("\nwant:\n%s}}}\ngot:\n%s}}}\n", want, buf.String()) 295 | } 296 | } 297 | 298 | func TestItalicStyle(t *testing.T) { 299 | var buf bytes.Buffer 300 | from := `Hello world` 301 | 302 | // The default mode 303 | err := Convert(&buf, strings.NewReader(from), nil) 304 | if err != nil { 305 | t.Fatal(err) 306 | } 307 | 308 | want := `_Hello world_ 309 | ` 310 | if buf.String() != want { 311 | t.Errorf("\nwant:\n%s}}}\ngot:\n%s}}}\n", want, buf.String()) 312 | } 313 | 314 | buf.Reset() 315 | 316 | // Change the italic style to asterix 317 | err = Convert(&buf, strings.NewReader(from), &Option{ItalicsAsterix: true}) 318 | if err != nil { 319 | t.Fatal(err) 320 | } 321 | 322 | want = `*Hello world* 323 | ` 324 | if buf.String() != want { 325 | t.Errorf("\nwant:\n%s}}}\ngot:\n%s}}}\n", want, buf.String()) 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /testdata/test001.html: -------------------------------------------------------------------------------- 1 | Hello Golang! 2 | -------------------------------------------------------------------------------- /testdata/test001.md: -------------------------------------------------------------------------------- 1 | Hello **Golang**\! 2 | -------------------------------------------------------------------------------- /testdata/test002.html: -------------------------------------------------------------------------------- 1 | Hello Golang! 2 | -------------------------------------------------------------------------------- /testdata/test002.md: -------------------------------------------------------------------------------- 1 | Hello _Golang_\! 2 | -------------------------------------------------------------------------------- /testdata/test003.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /testdata/test003.md: -------------------------------------------------------------------------------- 1 | 2 | * foo 3 | * bar 4 | * baz 5 | 6 | 7 | -------------------------------------------------------------------------------- /testdata/test004.html: -------------------------------------------------------------------------------- 1 |
    2 |
  1. foo
  2. 3 |
  3. bar
  4. 4 |
  5. baz
  6. 5 |
6 | -------------------------------------------------------------------------------- /testdata/test004.md: -------------------------------------------------------------------------------- 1 | 2 | 1. foo 3 | 2. bar 4 | 3. baz 5 | 6 | 7 | -------------------------------------------------------------------------------- /testdata/test005.html: -------------------------------------------------------------------------------- 1 |
2 | blah, blah, blah 3 |
4 | 5 |
6 |
7 | blah, blah, blah 8 |
9 |
10 | -------------------------------------------------------------------------------- /testdata/test005.md: -------------------------------------------------------------------------------- 1 | > blah, blah, blah 2 | 3 | > > blah, blah, blah 4 | 5 | 6 | -------------------------------------------------------------------------------- /testdata/test006.html: -------------------------------------------------------------------------------- 1 |
 2 | void main(void) {
 3 |   puts("hello world");
 4 | }
 5 | 
6 | 7 |
 8 | void main(void) {
 9 |   puts("hello world");
10 | }
11 | 
12 | 13 |
void main(void) {
14 |   puts("hello world");
15 | }
16 | 
17 | 18 |
foo
19 | -------------------------------------------------------------------------------- /testdata/test006.md: -------------------------------------------------------------------------------- 1 | ``` 2 | void main(void) { 3 | puts("hello world"); 4 | } 5 | ``` 6 | 7 | ``` 8 | void main(void) { 9 | puts("hello world"); 10 | } 11 | ``` 12 | 13 | ``` 14 | void main(void) { 15 | puts("hello world"); 16 | } 17 | ``` 18 | 19 | ``` 20 | foo 21 | ``` 22 | 23 | 24 | -------------------------------------------------------------------------------- /testdata/test007.html: -------------------------------------------------------------------------------- 1 |

GitHub

2 | 3 |

GitHub

4 | 5 |

GitHub

6 | -------------------------------------------------------------------------------- /testdata/test007.md: -------------------------------------------------------------------------------- 1 | [GitHub](https://github.com/) 2 | 3 | [GitHub](https://github.com/ "GitHub Homepage") 4 | 5 | [GitHub]() 6 | 7 | 8 | -------------------------------------------------------------------------------- /testdata/test008.html: -------------------------------------------------------------------------------- 1 |

My Picture

2 | 3 |

My Picture

4 | -------------------------------------------------------------------------------- /testdata/test008.md: -------------------------------------------------------------------------------- 1 | ![My Picture](https://example.com/image.jpg) 2 | 3 | ![My Picture](https://example.com/image.jpg "picture title") 4 | 5 | 6 | -------------------------------------------------------------------------------- /testdata/test009.html: -------------------------------------------------------------------------------- 1 | Yes, We Can No, We Can't 2 | -------------------------------------------------------------------------------- /testdata/test009.md: -------------------------------------------------------------------------------- 1 | ~~Yes, We Can~~ ~~No, We Can't~~ 2 | -------------------------------------------------------------------------------- /testdata/test010.html: -------------------------------------------------------------------------------- 1 |

Elit earum porro doloribus exercitationem in quis Natus vero ad impedit est facere adipisci. Et delectus alias nesciunt quo quod similique, voluptas dolore alias temporibus dolor Corporis obcaecati maxime possimus.

2 | 3 |

Amet est harum nihil fugiat dicta Odio laboriosam provident necessitatibus minus aperiam quisquam. Accusamus repellat sapiente sunt in provident. Iste voluptatum voluptas facilis in libero Fugit vel dicta illum labore

4 | -------------------------------------------------------------------------------- /testdata/test010.md: -------------------------------------------------------------------------------- 1 | Elit earum porro doloribus exercitationem in quis Natus vero ad impedit est facere adipisci. Et delectus alias nesciunt quo quod similique, voluptas dolore alias temporibus dolor Corporis obcaecati maxime possimus. 2 | 3 | Amet est harum nihil fugiat dicta Odio laboriosam provident necessitatibus minus aperiam quisquam. Accusamus repellat sapiente sunt in provident. Iste voluptatum voluptas facilis in libero Fugit vel dicta illum labore 4 | 5 | 6 | -------------------------------------------------------------------------------- /testdata/test011.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | foo bar 4 | bar baz 5 |
6 |
7 | 8 |
9 |
foo bar
bar baz
10 |
11 | -------------------------------------------------------------------------------- /testdata/test011.md: -------------------------------------------------------------------------------- 1 | foo bar bar baz 2 | 3 | foo bar 4 | 5 | bar baz 6 | 7 | 8 | -------------------------------------------------------------------------------- /testdata/test012.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
foo1bar1
foo2baあああ
11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
いいい 15 | いいbar1
foo2baあああ
23 | 24 | 25 | 26 | 28 | 29 | 30 | 31 | 32 | 33 |
いいい 27 | いいbar1
foo2
34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 |
RequestHandled inWait timeActual response time
/4s04s
/ping4s4s8s
/dfd5s8s13s
/foot3s52s
73 | -------------------------------------------------------------------------------- /testdata/test012.md: -------------------------------------------------------------------------------- 1 | |foo1|bar1 | 2 | |----|--------| 3 | |foo2|baあああ| 4 | 5 | |いいい いい|bar1 | 6 | |-----------|--------| 7 | |foo2 |baあああ| 8 | 9 | |いいい いい|bar1| 10 | |-----------|----| 11 | |foo2 | | 12 | 13 | |Request|Handled in|Wait time|Actual response time| 14 | |-------|----------|---------|--------------------| 15 | |/ |4s |0 |4s | 16 | |/ping |4s |4s |8s | 17 | |/dfd |5s |8s |13s | 18 | |/foot |3s |5 |2s | 19 | 20 | 21 | -------------------------------------------------------------------------------- /testdata/test013.html: -------------------------------------------------------------------------------- 1 |

H1

2 | 3 |

H2

4 | 5 |

H3

6 | 7 |

H4

8 | 9 |
H5
10 | 11 |
H6
12 | -------------------------------------------------------------------------------- /testdata/test013.md: -------------------------------------------------------------------------------- 1 | # H1 2 | 3 | ## H2 4 | 5 | ### H3 6 | 7 | #### H4 8 | 9 | ##### H5 10 | 11 | ###### H6 12 | 13 | 14 | -------------------------------------------------------------------------------- /testdata/test014.html: -------------------------------------------------------------------------------- 1 | inline code. 2 | -------------------------------------------------------------------------------- /testdata/test014.md: -------------------------------------------------------------------------------- 1 | inline `code`. 2 | -------------------------------------------------------------------------------- /testdata/test015.html: -------------------------------------------------------------------------------- 1 |
2 | package main
3 |
4 | import (
5 |     "fmt"
6 | )
7 |
8 | func main() {
9 |     fmt.Println("hello world")
10 | }
11 |
12 | 13 |
foo
14 | -------------------------------------------------------------------------------- /testdata/test015.md: -------------------------------------------------------------------------------- 1 | ``` 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | ) 7 | 8 | func main() { 9 | fmt.Println("hello world") 10 | } 11 | ``` 12 | 13 | ``` 14 | foo 15 | ``` 16 | 17 | 18 | -------------------------------------------------------------------------------- /testdata/test016.html: -------------------------------------------------------------------------------- 1 | hello
world 2 | -------------------------------------------------------------------------------- /testdata/test016.md: -------------------------------------------------------------------------------- 1 | **hello** 2 | 3 | [world](#) 4 | -------------------------------------------------------------------------------- /testdata/test017.html: -------------------------------------------------------------------------------- 1 | foo
bar 2 | -------------------------------------------------------------------------------- /testdata/test017.md: -------------------------------------------------------------------------------- 1 | foo 2 | 3 | --- 4 | 5 | bar 6 | -------------------------------------------------------------------------------- /testdata/test018.html: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /testdata/test018.md: -------------------------------------------------------------------------------- 1 | 2 | * nest1 3 | 1. nest1\-1 4 | 2. nest1\-2 5 | 3. nest1\-3 6 | * nest1\-3\-1 7 | 4. nest1\-4 8 | * nest2 9 | * nest2\-1 10 | * nest2\-2 11 | 12 | 13 | -------------------------------------------------------------------------------- /testdata/test019.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /testdata/test019.md: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /testdata/test020.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /testdata/test020.md: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /testdata/test021.html: -------------------------------------------------------------------------------- 1 |

>100 = > 29

2 | -------------------------------------------------------------------------------- /testdata/test021.md: -------------------------------------------------------------------------------- 1 | \>100 = \> 29 2 | 3 | 4 | -------------------------------------------------------------------------------- /testdata/test022.html: -------------------------------------------------------------------------------- 1 |
>100 = > 29
2 | -------------------------------------------------------------------------------- /testdata/test022.md: -------------------------------------------------------------------------------- 1 | ``` 2 | >100 = > 29 3 | ``` 4 | 5 | 6 | -------------------------------------------------------------------------------- /testdata/test023.html: -------------------------------------------------------------------------------- 1 | many more examples 2 | -------------------------------------------------------------------------------- /testdata/test023.md: -------------------------------------------------------------------------------- 1 | [many](many) [more](more) [examples](examples) 2 | -------------------------------------------------------------------------------- /testdata/test024.html: -------------------------------------------------------------------------------- 1 | 21 |

Text after list

22 | -------------------------------------------------------------------------------- /testdata/test024.md: -------------------------------------------------------------------------------- 1 | 2 | * nest1 3 | 1. nest1\-1.1 4 | nest1\-1.2 5 | 2. nest1\-2 6 | 3. nest1\-3 7 | * nest1\-3\-1 8 | 4. nest1\-4 9 | * nest2 10 | * nest2\-1 11 | * nest2\-2 12 | 13 | Text after list 14 | 15 | 16 | -------------------------------------------------------------------------------- /testdata/test025.html: -------------------------------------------------------------------------------- 1 | 31 |
    32 |
  1. Example 33 |
      34 |
    1. Hello
    2. 35 |
    3. Hi
    4. 36 |
    37 |
  2. 38 |
39 | 54 | -------------------------------------------------------------------------------- /testdata/test025.md: -------------------------------------------------------------------------------- 1 | 2 | * Make sure it wraps appropriately. That way it will look really neat. 3 | * Funny 4 | |hello|hi | 5 | |-----|----| 6 | |wow |wawu| 7 | ## Hello 8 | * Amazing 9 | * even funnier 10 | * amazong 11 | * whatver 12 | * Hmmm 13 | 14 | 15 | 1. [Example](https://example.com) 16 | 1. Hello 17 | 2. Hi 18 | 19 | 20 | * Interesting. 21 | * Funny 22 | * Amazing 23 | * even funnier 24 | * amazong 25 | * whatver 26 | * Hmmm 27 | 28 | 29 | --------------------------------------------------------------------------------