├── .gitignore ├── LICENSE ├── README.md ├── go.mod ├── main.go ├── mdproc ├── headings.go ├── hugo.go ├── hugo_test.go ├── links.go ├── preproc.go └── preproc_test.go ├── pipe ├── pipe.go ├── stream.go └── streamitem.go ├── reader.go └── writer.go /.gitignore: -------------------------------------------------------------------------------- 1 | md2gmi 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2021, dre@nox.im 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Report Card](https://goreportcard.com/badge/github.com/n0x1m/md2gmi)](https://goreportcard.com/report/github.com/n0x1m/md2gmi) 2 | [![GoDoc](https://godoc.org/github.com/n0x1m/md2gmi?status.svg)](https://godoc.org/github.com/n0x1m/md2gmi) 3 | 4 | # md2gmi 5 | 6 | Convert Markdown to Gemini [gemtext](https://gemini.circumlunar.space/docs/gemtext.gmi) with Go. 7 | Working with streams and UNIX pipes, utilizing Go channels. Processing streams line by line is 8 | probably slightly more complex than it needs to be, as I was toying with channels and state 9 | machines. 10 | 11 | Internally md2gmi does a 1st pass that constructs the blocks of single lines for gemtext from one or 12 | multiple lines of an input stream. These blocks are then streamed to the 2nd passes. The 2nd pass 13 | will convert hugo front matters, links, fix headings etc. These stages/passes can be composed and 14 | chained with go pipelines. The output sink is either a file or stdout. 15 | 16 | ## Usage 17 | 18 | ```plain 19 | Usage of ./md2gmi: 20 | -i string 21 | specify a .md (Markdown) file to read from, otherwise stdin (default) 22 | -o string 23 | specify a .gmi (gemtext) file to write to, otherwise stdout (default) 24 | ``` 25 | 26 | ### Example 27 | 28 | go get github.com/n0x1m/md2gmi 29 | cat file.md | md2gmi 30 | md2gmi -i file.md -o file.gmi 31 | 32 | The top part of this readme parses from 33 | 34 | ```markdown 35 | Convert Markdown to Gemini [gemtext](https://gemini.circumlunar.space/docs/gemtext.gmi) markup with 36 | Go. Working with streams and pipes for UNIX like behavior utilizing Go channels. Processing streams 37 | line by line is slightly more complex than it needs to be as I'm playing with channels and state 38 | machines here. 39 | 40 | > this is 41 | a quote 42 | 43 | 44 | See the [gemini 45 | protocol](https://gemini.circumlunar.space/) and the [protocol 46 | spec](https://gemini.circumlunar.space/docs/specification.gmi). 47 | ``` 48 | 49 | to 50 | 51 | ```markdown 52 | Convert Markdown to Gemini gemtext[1] markup with Go. Working with streams and pipes for UNIX like behavior utilizing Go channels. Processing streams line by line is slightly more complex than it needs to be as I'm playing with channels and state machines here. 53 | 54 | => https://gemini.circumlunar.space/docs/gemtext.gmi 1: gemtext 55 | 56 | > this is a quote 57 | See the gemini protocol[1] and the protocol spec[2]. 58 | 59 | => https://gemini.circumlunar.space/ 1: gemini protocol 60 | => https://gemini.circumlunar.space/docs/specification.gmi 2: protocol spec 61 | ``` -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/n0x1m/md2gmi 2 | 3 | go 1.16 4 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/n0x1m/md2gmi/mdproc" 9 | "github.com/n0x1m/md2gmi/pipe" 10 | ) 11 | 12 | func main() { 13 | var in, out string 14 | 15 | flag.StringVar(&in, "i", "", "specify a .md (Markdown) file to read from, otherwise stdin (default)") 16 | flag.StringVar(&out, "o", "", "specify a .gmi (gemtext) file to write to, otherwise stdout (default)") 17 | flag.Parse() 18 | 19 | r, err := reader(in) 20 | if err != nil { 21 | fmt.Fprint(os.Stderr, err.Error()) 22 | os.Exit(1) 23 | } 24 | 25 | w, err := writer(out) 26 | if err != nil { 27 | fmt.Fprint(os.Stderr, err.Error()) 28 | os.Exit(1) 29 | } 30 | 31 | s := pipe.New() 32 | s.Use(mdproc.Preprocessor()) 33 | s.Use(mdproc.RemoveFrontMatter) 34 | s.Use(mdproc.FormatHeadings) 35 | s.Use(mdproc.FormatLinks) 36 | s.Handle(source(r), sink(w)) 37 | } 38 | -------------------------------------------------------------------------------- /mdproc/headings.go: -------------------------------------------------------------------------------- 1 | package mdproc 2 | 3 | import ( 4 | "bytes" 5 | "regexp" 6 | 7 | "github.com/n0x1m/md2gmi/pipe" 8 | ) 9 | 10 | func FormatHeadings(in chan pipe.StreamItem) chan pipe.StreamItem { 11 | out := make(chan pipe.StreamItem) 12 | 13 | go func() { 14 | re := regexp.MustCompile(`^[#]{4,}`) 15 | re2 := regexp.MustCompile(`^(#+)[^# ]`) 16 | 17 | for b := range in { 18 | // fix up more than 4 levels 19 | data := re.ReplaceAll(b.Payload(), []byte("###")) 20 | // ensure we have a space 21 | sub := re2.FindSubmatch(data) 22 | if len(sub) > 0 { 23 | data = bytes.Replace(data, sub[1], append(sub[1], []byte(" ")...), 1) 24 | } 25 | // writeback 26 | out <- pipe.NewItem(b.Index(), data) 27 | } 28 | 29 | close(out) 30 | }() 31 | 32 | return out 33 | } 34 | -------------------------------------------------------------------------------- /mdproc/hugo.go: -------------------------------------------------------------------------------- 1 | package mdproc 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | 8 | "github.com/n0x1m/md2gmi/pipe" 9 | ) 10 | 11 | func RemoveFrontMatter(in chan pipe.StreamItem) chan pipe.StreamItem { 12 | out := make(chan pipe.StreamItem) 13 | 14 | go func() { 15 | // delete the entire front matter 16 | re := regexp.MustCompile(`---.*---`) 17 | // but parse out the title as we want to reinject it 18 | re2 := regexp.MustCompile(`title:[ "]*([a-zA-Z0-9 :!'@#$%^&*)(]+)["]*`) 19 | 20 | for b := range in { 21 | data := b.Payload() 22 | for _, match := range re.FindAllSubmatch(data, -1) { 23 | data = bytes.Replace(data, match[0], []byte(""), 1) 24 | for _, title := range re2.FindAllSubmatch(match[0], 1) { 25 | // add title 26 | data = []byte(fmt.Sprintf("# %s\n\n", title[1])) 27 | } 28 | } 29 | out <- pipe.NewItem(b.Index(), data) 30 | } 31 | 32 | close(out) 33 | }() 34 | 35 | return out 36 | } 37 | -------------------------------------------------------------------------------- /mdproc/hugo_test.go: -------------------------------------------------------------------------------- 1 | package mdproc_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/n0x1m/md2gmi/mdproc" 7 | "github.com/n0x1m/md2gmi/pipe" 8 | ) 9 | 10 | func TestMdDocument2Gmi(t *testing.T) { 11 | t.Parallel() 12 | 13 | document := `--- 14 | title: "Dre's log" 15 | --- 16 | 17 | nox, Latin "night; darkness" 18 | 19 | ` + "```" + ` 20 | ___ 21 | (o,o) < nox.im 22 | {` + "`" + `"'} Fiat lux. 23 | -"-"- 24 | ` + "```" + ` 25 | 26 | [Gemini](gemini://nox.im) · [RSS](/index.xml) · [About](/about) · [Github](https://github.com/n0x1m) 27 | 28 | Contact me via ` + "`" + `dre@nox.im` + "`" + `. You may use my [age](/snippets/actually-good-encryption/) public key to send me files securely: ` + "`" + `age1vpyptw64mz2vhtj7tvfh9saj0y8zy8fguety5n3wpmwzpkn0rd6swh02an` + "`" + `. 29 | 30 | 34 | 35 | ## Posts 36 | 37 | ` 38 | 39 | gmiout := `# Dre's log 40 | 41 | nox, Latin "night; darkness" 42 | 43 | ` + "```" + ` 44 | ___ 45 | (o,o) < nox.im 46 | {` + "`" + `"'} Fiat lux. 47 | -"-"- 48 | ` + "```" + ` 49 | 50 | Gemini[1] · RSS[2] · About[3] · Github[4] 51 | 52 | => gemini://nox.im 1: Gemini 53 | => /index.xml 2: RSS 54 | => /about 3: About 55 | => https://github.com/n0x1m 4: Github 56 | 57 | Contact me via ` + "`" + `dre@nox.im` + "`" + `. You may use my age[1] public key to send me files securely: ` + "`" + `age1vpyptw64mz2vhtj7tvfh9saj0y8zy8fguety5n3wpmwzpkn0rd6swh02an` + "`" + `. 58 | 59 | => /snippets/actually-good-encryption/ 1: age 60 | 61 | ## Posts 62 | 63 | ` 64 | 65 | s := pipe.New() 66 | s.Use(mdproc.Preprocessor()) 67 | s.Use(mdproc.RemoveFrontMatter) 68 | s.Use(mdproc.FormatHeadings) 69 | s.Use(mdproc.FormatLinks) 70 | s.Handle(source(t, document), sink(t, gmiout)) 71 | } 72 | -------------------------------------------------------------------------------- /mdproc/links.go: -------------------------------------------------------------------------------- 1 | package mdproc 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | 8 | "github.com/n0x1m/md2gmi/pipe" 9 | ) 10 | 11 | func FormatLinks(in chan pipe.StreamItem) chan pipe.StreamItem { 12 | out := make(chan pipe.StreamItem) 13 | go func() { 14 | 15 | for b := range in { 16 | data := b.Payload() 17 | 18 | // we will receive the entire block as one when fences are on. 19 | if hasFence(data) { 20 | out <- pipe.NewItem(b.Index(), b.Payload()) 21 | 22 | continue 23 | } 24 | 25 | out <- pipe.NewItem(b.Index(), formatLinks(b.Payload())) 26 | } 27 | 28 | close(out) 29 | }() 30 | 31 | return out 32 | } 33 | 34 | func formatLinks(data []byte) []byte { 35 | // find link name and url 36 | var buffer []byte 37 | 38 | re := regexp.MustCompile(`!?\[([^\]*]*)\]\(([^ ]*)\)`) 39 | 40 | for i, match := range re.FindAllSubmatch(data, -1) { 41 | replaceWithIndex := append(match[1], fmt.Sprintf("[%d]", i+1)...) 42 | data = bytes.Replace(data, match[0], replaceWithIndex, 1) 43 | // append entry to buffer to be added later 44 | link := fmt.Sprintf("=> %s %d: %s\n", match[2], i+1, match[1]) 45 | buffer = append(buffer, link...) 46 | } 47 | // append links to that paragraph 48 | if len(buffer) > 0 { 49 | data = append(data, buffer...) 50 | data = append(data, []byte("\n")...) 51 | } 52 | 53 | return data 54 | } 55 | -------------------------------------------------------------------------------- /mdproc/preproc.go: -------------------------------------------------------------------------------- 1 | package mdproc 2 | 3 | import ( 4 | "bytes" 5 | "regexp" 6 | 7 | "github.com/n0x1m/md2gmi/pipe" 8 | ) 9 | 10 | // state function. 11 | type stateFn func(*fsm, []byte) stateFn 12 | 13 | // state machine. 14 | type fsm struct { 15 | state stateFn 16 | 17 | i int 18 | out chan pipe.StreamItem 19 | 20 | // combining multiple input lines 21 | multiLineBlockMode int 22 | blockBuffer []byte 23 | sendBuffer []byte 24 | // if we have a termination rule to abide, e.g. implied code fences 25 | pending []byte 26 | } 27 | 28 | func Preprocessor() pipe.Pipeline { 29 | return (&fsm{}).pipeline 30 | } 31 | 32 | func (m *fsm) pipeline(in chan pipe.StreamItem) chan pipe.StreamItem { 33 | m.out = make(chan pipe.StreamItem) 34 | 35 | go func() { 36 | for m.state = normal; m.state != nil; { 37 | b, ok := <-in 38 | if !ok { 39 | m.blockFlush() 40 | m.sync() 41 | close(m.out) 42 | m.state = nil 43 | 44 | continue 45 | } 46 | 47 | m.state = m.state(wrap(m, b.Payload())) 48 | m.sync() 49 | } 50 | }() 51 | 52 | return m.out 53 | } 54 | 55 | func wrap(m *fsm, data []byte) (*fsm, []byte) { 56 | var scount, ecount int 57 | if scount = countStart(data, ""); ecount > 0 { 62 | m.multiLineBlockMode -= ecount 63 | } 64 | 65 | // clip entire line if no control sequences present 66 | if (m.multiLineBlockMode > 0 && scount == 0 && ecount == 0) || m.multiLineBlockMode > 1 { 67 | data = data[:0] 68 | return m, data 69 | } 70 | 71 | // clip data past first occurrence 72 | if scount > 0 { 73 | data = data[:bytes.Index(data, []byte(""); ecount > 0 { 78 | data = data[bytes.LastIndex(data, []byte("-->"))+3:] 79 | } 80 | 81 | return m, data 82 | } 83 | 84 | func countStart(data []byte, pattern string) int { 85 | return bytes.Count(data, []byte(pattern)) 86 | } 87 | 88 | func countEnd(data []byte, pattern string) int { 89 | return bytes.Count(data, []byte(pattern)) 90 | } 91 | 92 | func (m *fsm) sync() { 93 | if len(m.sendBuffer) > 0 { 94 | m.sendBuffer = append(m.sendBuffer, '\n') 95 | m.out <- pipe.NewItem(m.i, m.sendBuffer) 96 | m.sendBuffer = m.sendBuffer[:0] 97 | m.i++ 98 | } 99 | } 100 | 101 | func (m *fsm) softBlockFlush() { 102 | if m.multiLineBlockMode > 0 { 103 | return 104 | } 105 | 106 | m.blockFlush() 107 | } 108 | 109 | func (m *fsm) blockFlush() { 110 | // blockBuffer to sendbuffer 111 | m.sendBuffer = append(m.sendBuffer, m.blockBuffer...) 112 | m.blockBuffer = m.blockBuffer[:0] 113 | 114 | // pending to sendbuffer too 115 | if len(m.pending) > 0 { 116 | m.sendBuffer = append(m.sendBuffer, m.pending...) 117 | m.pending = m.pending[:0] 118 | } 119 | } 120 | 121 | func isTerminated(data []byte) bool { 122 | return len(data) > 0 && data[len(data)-1] != '.' 123 | } 124 | 125 | func triggerBreak(data []byte) bool { 126 | if len(data) == 0 || len(data) == 1 && data[0] == '\n' { 127 | return true 128 | } 129 | 130 | switch data[len(data)-1] { 131 | case '.': 132 | fallthrough 133 | case ';': 134 | fallthrough 135 | case ':': 136 | return true 137 | } 138 | 139 | return false 140 | } 141 | 142 | func handleList(data []byte) ([]byte, bool) { 143 | // match italic, bold 144 | nolist := regexp.MustCompile(`[\*_](.*)[\*_]`) 145 | nosub := nolist.FindSubmatch(data) 146 | // match lists 147 | list := regexp.MustCompile(`^([ \t]*[-*^]{1,1})[^*-]`) 148 | sub := list.FindSubmatch(data) 149 | // if lists, collapse to single level 150 | if len(sub) > 1 && len(nosub) <= 1 { 151 | return bytes.Replace(data, sub[1], []byte("-"), 1), true 152 | } 153 | 154 | return data, false 155 | } 156 | 157 | func hasFence(data []byte) bool { 158 | return bytes.Contains(data, []byte("```")) 159 | } 160 | 161 | func needsFence(data []byte) bool { 162 | return len(data) >= 4 && string(data[0:4]) == " " 163 | } 164 | 165 | func normalText(m *fsm, data []byte) stateFn { 166 | if len(bytes.TrimSpace(data)) == 0 { 167 | return normal 168 | } 169 | 170 | if data, isList := handleList(data); isList { 171 | m.blockBuffer = append(m.blockBuffer, data...) 172 | m.softBlockFlush() 173 | 174 | return list 175 | } 176 | 177 | if hasFence(data) { 178 | m.blockBuffer = append(data, '\n') 179 | 180 | return fence 181 | } 182 | 183 | if needsFence(data) { 184 | m.blockBuffer = append(m.blockBuffer, []byte("```\n")...) 185 | m.blockBuffer = append(m.blockBuffer, append(data[4:], '\n')...) 186 | m.pending = []byte("```\n") 187 | 188 | return toFence 189 | } 190 | 191 | if isTerminated(data) { 192 | m.blockBuffer = append(m.blockBuffer, data...) 193 | m.blockBuffer = append(m.blockBuffer, ' ') 194 | 195 | return paragraph 196 | } 197 | 198 | m.blockBuffer = append(m.blockBuffer, append(data, '\n')...) 199 | m.softBlockFlush() 200 | 201 | return normal 202 | } 203 | 204 | func normal(m *fsm, data []byte) stateFn { 205 | return normalText(m, data) 206 | } 207 | 208 | func list(m *fsm, data []byte) stateFn { 209 | if data, isList := handleList(data); isList { 210 | data = append(data, '\n') 211 | m.blockBuffer = append(m.blockBuffer, data...) 212 | 213 | return list 214 | } 215 | 216 | m.softBlockFlush() 217 | 218 | return normalText(m, data) 219 | } 220 | 221 | func fence(m *fsm, data []byte) stateFn { 222 | m.blockBuffer = append(m.blockBuffer, append(data, '\n')...) 223 | // second fence returns to normal 224 | if hasFence(data) { 225 | m.softBlockFlush() 226 | 227 | return normal 228 | } 229 | 230 | return fence 231 | } 232 | 233 | func toFence(m *fsm, data []byte) stateFn { 234 | if needsFence(data) { 235 | data = append(data, '\n') 236 | m.blockBuffer = append(m.blockBuffer, data[4:]...) 237 | 238 | return toFence 239 | } 240 | 241 | m.softBlockFlush() 242 | 243 | return normalText(m, data) 244 | } 245 | 246 | func paragraph(m *fsm, data []byte) stateFn { 247 | if triggerBreak(data) { 248 | m.blockBuffer = append(m.blockBuffer, data...) 249 | m.blockBuffer = bytes.TrimSpace(m.blockBuffer) 250 | // TODO, remove double spaces inside paragraphs 251 | m.blockBuffer = append(m.blockBuffer, '\n') 252 | m.softBlockFlush() 253 | 254 | return normal 255 | } 256 | 257 | m.blockBuffer = append(m.blockBuffer, data...) 258 | m.blockBuffer = append(m.blockBuffer, []byte(" ")...) 259 | 260 | return paragraph 261 | } 262 | -------------------------------------------------------------------------------- /mdproc/preproc_test.go: -------------------------------------------------------------------------------- 1 | package mdproc_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/n0x1m/md2gmi/mdproc" 8 | "github.com/n0x1m/md2gmi/pipe" 9 | ) 10 | 11 | const ( 12 | input = `--- 13 | title: "This is the Title!" 14 | categories: [a,b] 15 | --- 16 | 17 | 18 | comment --> 19 | 20 | 25 | and [with a](link); 26 | --> 27 | > this is 28 | a quote 29 | 30 | *not a list* 32 | **also not a list** 33 | 34 | - this 35 | - is 36 | * an unordered 37 | * list 38 | 39 | This is 40 | a paragraph with a link to the [gemini protocol](https://en.wikipedia.org/wiki/Gemini_(protocol)). 41 | 42 | ` + "```" + ` 43 | this is multi 44 | line code 45 | ` + "```" + ` 46 | 47 | and 48 | 49 | this is code too` 50 | 51 | preproc = `--- title: "This is the Title!" categories: [a,b] --- 52 | 53 | > this is a quote 54 | 55 | *not a list* 56 | 57 | **also not a list** 58 | 59 | - this 60 | - is 61 | - an unordered 62 | - list 63 | 64 | This is a paragraph with a link to the [gemini protocol](https://en.wikipedia.org/wiki/Gemini_(protocol)). 65 | 66 | ` + "```" + ` 67 | this is multi 68 | line code 69 | ` + "```" + ` 70 | 71 | and 72 | 73 | ` + "```" + ` 74 | this is code too 75 | ` + "```" + ` 76 | 77 | ` 78 | 79 | gmi = `# This is the Title! 80 | 81 | > this is a quote 82 | 83 | *not a list* 84 | 85 | **also not a list** 86 | 87 | - this 88 | - is 89 | - an unordered 90 | - list 91 | 92 | This is a paragraph with a link to the gemini protocol[1]. 93 | 94 | => https://en.wikipedia.org/wiki/Gemini_(protocol) 1: gemini protocol 95 | 96 | ` + "```" + ` 97 | this is multi 98 | line code 99 | ` + "```" + ` 100 | 101 | and 102 | 103 | ` + "```" + ` 104 | this is code too 105 | ` + "```" + ` 106 | 107 | ` 108 | ) 109 | 110 | func source(t *testing.T, in string) func() chan pipe.StreamItem { 111 | t.Helper() 112 | 113 | return func() chan pipe.StreamItem { 114 | data := make(chan pipe.StreamItem, len(strings.Split(in, "\n"))) 115 | for _, line := range strings.Split(in, "\n") { 116 | data <- pipe.NewItem(0, []byte(line)) 117 | } 118 | 119 | close(data) 120 | 121 | return data 122 | } 123 | } 124 | 125 | func sink(t *testing.T, expected string) func(dest chan pipe.StreamItem) { 126 | t.Helper() 127 | 128 | return func(dest chan pipe.StreamItem) { 129 | var data []byte 130 | for in := range dest { 131 | data = append(data, in.Payload()...) 132 | } 133 | 134 | if string(data) != expected { 135 | t.Errorf("mismatch, expected '%s' but was '%s'", expected, data) 136 | } 137 | } 138 | } 139 | 140 | func TestPreproc(t *testing.T) { 141 | t.Parallel() 142 | 143 | s := pipe.New() 144 | s.Use(mdproc.Preprocessor()) 145 | s.Handle(source(t, input), sink(t, preproc)) 146 | } 147 | 148 | func TestMd2Gmi(t *testing.T) { 149 | t.Parallel() 150 | 151 | s := pipe.New() 152 | s.Use(mdproc.Preprocessor()) 153 | s.Use(mdproc.RemoveFrontMatter) 154 | s.Use(mdproc.FormatHeadings) 155 | s.Use(mdproc.FormatLinks) 156 | s.Handle(source(t, input), sink(t, gmi)) 157 | } 158 | -------------------------------------------------------------------------------- /pipe/pipe.go: -------------------------------------------------------------------------------- 1 | package pipe 2 | 3 | type Connector chan StreamItem 4 | 5 | type Source func() chan StreamItem 6 | type Sink func(chan StreamItem) 7 | type Pipeline func(chan StreamItem) chan StreamItem 8 | -------------------------------------------------------------------------------- /pipe/stream.go: -------------------------------------------------------------------------------- 1 | package pipe 2 | 3 | type Pipelines []Pipeline 4 | 5 | type Stream struct { 6 | nodes []Pipeline 7 | } 8 | 9 | func New() *Stream { 10 | return &Stream{} 11 | } 12 | 13 | // Use appends a pipeline processor to the Stream pipeline stack. 14 | func (s *Stream) Use(nodes ...Pipeline) { 15 | s.nodes = append(s.nodes, nodes...) 16 | } 17 | 18 | // chain builds a Connector composed of an inline pipeline stack and endpoint 19 | // processor in the order they are passed. 20 | func chain(nodes []Pipeline, src Connector) Connector { 21 | c := nodes[0](src) 22 | for i := 1; i < len(nodes); i++ { 23 | c = nodes[i](c) 24 | } 25 | 26 | return c 27 | } 28 | 29 | // Handle registers a source and maps it to a sink. 30 | func (s *Stream) Handle(src Source, dest Sink) { 31 | dest(chain(s.nodes, src())) 32 | } 33 | -------------------------------------------------------------------------------- /pipe/streamitem.go: -------------------------------------------------------------------------------- 1 | package pipe 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type StreamItem struct { 8 | ctx context.Context 9 | index int 10 | payload []byte 11 | } 12 | 13 | func (s *StreamItem) Context() context.Context { 14 | return s.ctx 15 | } 16 | 17 | func NewItem(index int, payload []byte) StreamItem { 18 | return newItem(context.Background(), index, payload) 19 | } 20 | 21 | func NewItemWithContext(ctx context.Context, index int, payload []byte) StreamItem { 22 | return newItem(ctx, index, payload) 23 | } 24 | 25 | func newItem(ctx context.Context, index int, payload []byte) StreamItem { 26 | s := StreamItem{ 27 | ctx: ctx, 28 | index: index, 29 | payload: make([]byte, len(payload)), 30 | } 31 | copy(s.payload, payload) 32 | 33 | return s 34 | } 35 | 36 | func (s *StreamItem) Index() int { 37 | return s.index 38 | } 39 | 40 | func (s *StreamItem) Payload() []byte { 41 | return s.payload 42 | } 43 | -------------------------------------------------------------------------------- /reader.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "github.com/n0x1m/md2gmi/pipe" 10 | ) 11 | 12 | func reader(in string) (io.Reader, error) { 13 | if in != "" { 14 | file, err := os.Open(in) 15 | if err != nil { 16 | return nil, fmt.Errorf("reader: %w", err) 17 | } 18 | 19 | return file, nil 20 | } 21 | 22 | return os.Stdin, nil 23 | } 24 | 25 | func source(r io.Reader) pipe.Source { 26 | return func() chan pipe.StreamItem { 27 | data := make(chan pipe.StreamItem) 28 | s := bufio.NewScanner(r) 29 | 30 | go func() { 31 | i := 0 32 | 33 | for s.Scan() { 34 | data <- pipe.NewItem(i, s.Bytes()) 35 | i++ 36 | } 37 | close(data) 38 | }() 39 | 40 | return data 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /writer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "github.com/n0x1m/md2gmi/pipe" 9 | ) 10 | 11 | func writer(out string) (io.Writer, error) { 12 | if out != "" { 13 | file, err := os.Create(out) 14 | if err != nil { 15 | return nil, fmt.Errorf("writer: %w", err) 16 | } 17 | 18 | return file, nil 19 | } 20 | 21 | return os.Stdout, nil 22 | } 23 | 24 | func sink(w io.Writer) pipe.Sink { 25 | return func(dest chan pipe.StreamItem) { 26 | for b := range dest { 27 | fmt.Fprint(w, string(b.Payload())) 28 | } 29 | } 30 | } 31 | --------------------------------------------------------------------------------