├── .gitignore ├── content ├── index.md └── deepdir │ ├── deep.md │ └── deeperdir │ └── deeper.md ├── main.go ├── go.mod ├── cmd ├── Default.go ├── Convert.go ├── util.go └── Run.go ├── LICENSE ├── README.md ├── mdfile └── MarkdownFile.go └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | tout 3 | -------------------------------------------------------------------------------- /content/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | metaData: "some metadata for you" 3 | --- 4 | 5 | # Some Content 6 | Here is some content -------------------------------------------------------------------------------- /content/deepdir/deep.md: -------------------------------------------------------------------------------- 1 | --- 2 | metaDescription: "A file found deep within the system" 3 | --- 4 | 5 | # A Deep File 6 | Hidden within a directory -------------------------------------------------------------------------------- /content/deepdir/deeperdir/deeper.md: -------------------------------------------------------------------------------- 1 | --- 2 | metaDescription: "Here I am, hidden away from you!" 3 | --- 4 | 5 | # An Even Deeper File 6 | Hidden away in a folder. -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/phillip-england/marki/cmd" 7 | "github.com/phillip-england/whip" 8 | ) 9 | 10 | func main() { 11 | 12 | cli, err := whip.New(cmd.NewDefault) 13 | if err != nil { 14 | fmt.Println(err.Error()) 15 | } 16 | 17 | cli.At("convert", cmd.NewConvert) 18 | cli.At("run", cmd.NewRun) 19 | 20 | err = cli.Run() 21 | if err != nil { 22 | fmt.Println(err.Error()) 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/phillip-england/marki 2 | 3 | go 1.25.3 4 | 5 | require ( 6 | github.com/alecthomas/chroma/v2 v2.20.0 7 | github.com/phillip-england/wherr v0.0.1 8 | github.com/yuin/goldmark v1.7.13 9 | github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 10 | github.com/yuin/goldmark-meta v1.1.0 11 | ) 12 | 13 | require github.com/phillip-england/whip v0.0.2 // indirect 14 | 15 | require ( 16 | github.com/dlclark/regexp2 v1.11.5 // indirect 17 | github.com/fsnotify/fsnotify v1.9.0 // indirect 18 | golang.org/x/sys v0.13.0 // indirect 19 | gopkg.in/yaml.v2 v2.3.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /cmd/Default.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/phillip-england/whip" 7 | ) 8 | 9 | type Default struct{} 10 | 11 | func NewDefault(app *whip.Cli) (whip.Cmd, error) { 12 | return Default{}, nil 13 | } 14 | 15 | func (cmd Default) Execute(app *whip.Cli) error { 16 | fmt.Print(`marki - a runtime for converting .md into .html 17 | 18 | run: 19 | marki run 20 | marki run ./README.md ./README.html dracula --watch 21 | marki run ./indir ./outdir dracula --watch 22 | 23 | *to avoid issues with commas, we use < 26 | marki convert dracula < is required in 'marki convert '") 20 | if err != nil { 21 | return Convert{}, wherr.Consume(wherr.Here(), err, "") 22 | } 23 | err = isValidTheme(theme) 24 | if err != nil { 25 | return Convert{}, wherr.Consume(wherr.Here(), err, "") 26 | } 27 | mdBytes, err := io.ReadAll(os.Stdin) 28 | if err != nil { 29 | return Convert{}, wherr.Consume(wherr.Here(), err, "") 30 | } 31 | return Convert{ 32 | Theme: theme, 33 | MdStr: string(mdBytes), 34 | }, nil 35 | } 36 | 37 | func (cmd Convert) Execute(cli *whip.Cli) error { 38 | mdFile, err := mdfile.NewMarkdownFileFromStr(cmd.MdStr, cmd.Theme) 39 | if err != nil { 40 | return wherr.Consume(wherr.Here(), err, "") 41 | } 42 | _, err = fmt.Print(mdFile.Html) 43 | if err != nil { 44 | fmt.Fprintln(os.Stderr, "Error writing HTML to stdout:", err) 45 | return wherr.Consume(wherr.Here(), err, "") 46 | } 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | metaContent: "a readme about marki" 3 | --- 4 | 5 | # marki 6 | A runtime for content-driven developers who just want to turn `.md` into `.html`. Run marki in the background, write your content, and use the generated html. Dead simple. 7 | 8 | ## Installation 9 | ```bash 10 | go install github.com/phillip-england/marki@latest 11 | ``` 12 | 13 | ## Usage 14 | ```bash 15 | run: 16 | marki run 17 | marki run ./indir ./outdir dracula 18 | marki run ./indir ./outdir dracula --watch 19 | marki run ./infile.md ./outfile.html dracula 20 | marki run ./infile.md ./outfile.html dracula --watch 21 | 22 | # to avoid issues with commas, we use < 25 | marki convert dracula < abap --watch 40 | ``` 41 | 42 | ## Metadata 43 | Use YAML-style frontmatter in your markdown to generate HTML `` tags for your content. For example, the following markdown: 44 | 45 | ```md 46 | --- 47 | metaDescription: "my description" 48 | --- 49 | # Content 50 | some markdown content 51 | ``` 52 | 53 | will result in the following HTML: 54 | ```html 55 | 56 |

Content

57 |

some markdown content

58 | ``` 59 | 60 | You can then split off the HTML by splitting the string by ``, making it easy to parse out meta content from UI content. -------------------------------------------------------------------------------- /cmd/util.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "math/rand" 7 | "os" 8 | "path/filepath" 9 | "time" 10 | ) 11 | 12 | func mkdirIfNotExists(outDir string) error { 13 | err := os.MkdirAll(outDir, 0755) 14 | if err != nil { 15 | return err 16 | } 17 | return nil 18 | } 19 | 20 | func dirClear(dirName string) error { 21 | err := filepath.WalkDir(dirName, func(path string, d fs.DirEntry, err error) error { 22 | if d.IsDir() { 23 | return nil 24 | } 25 | err = os.Remove(path) 26 | if err != nil { 27 | return err 28 | } 29 | return nil 30 | }) 31 | if err != nil { 32 | return err 33 | } 34 | return nil 35 | } 36 | 37 | func randomString(length int) string { 38 | charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 39 | seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) 40 | result := make([]byte, length) 41 | for i := range result { 42 | result[i] = charset[seededRand.Intn(len(charset))] 43 | } 44 | return string(result) 45 | } 46 | func dirExists(path string) bool { 47 | if _, err := os.Stat(path); os.IsNotExist(err) { 48 | return false 49 | } 50 | return true 51 | } 52 | 53 | func isValidTheme(theme string) error { 54 | validThemes := []string{ 55 | "abap", "algol", "algol_nu", "arduino", "autumn", "average", "base16-snazzy", 56 | "borland", "bw", "catppuccin-frappe", "catppuccin-latte", "catppuccin-macchiato", 57 | "catppuccin-mocha", "colorful", "doom-one", "doom-one2", "dracula", "emacs", 58 | "evergarden", "friendly", "fruity", "github-dark", "github", "gruvbox-light", 59 | "gruvbox", "hr_high_contrast", "hrdark", "igor", "lovelace", "manni", "modus-operandi", 60 | "modus-vivendi", "monokai", "monokailight", "murphy", "native", "nord", "nordic", 61 | "onedark", "onesenterprise", "paraiso-dark", "paraiso-light", "pastie", "perldoc", 62 | "pygments", "rainbow_dash", "rose-pine-dawn", "rose-pine-moon", "rose-pine", "rpgle", 63 | "rrt", "solarized-dark", "solarized-dark256", "solarized-light", "swapoff", "tango", 64 | "tokyonight-day", "tokyonight-moon", "tokyonight-night", "tokyonight-storm", "trac", 65 | "vim", "vs", "vulcan", "witchhazel", "xcode-dark", "xcode", 66 | } 67 | for _, vTheme := range validThemes { 68 | if theme == vTheme { 69 | return nil 70 | } 71 | } 72 | return fmt.Errorf(" [%s] is not a valid theme\nfor a list of valid themes see https://github.com/phillip-england/marki", theme) 73 | } 74 | -------------------------------------------------------------------------------- /mdfile/MarkdownFile.go: -------------------------------------------------------------------------------- 1 | package mdfile 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 11 | "github.com/phillip-england/wherr" 12 | "github.com/yuin/goldmark" 13 | highlighting "github.com/yuin/goldmark-highlighting/v2" 14 | meta "github.com/yuin/goldmark-meta" 15 | "github.com/yuin/goldmark/parser" 16 | "github.com/yuin/goldmark/renderer/html" 17 | ) 18 | 19 | type MarkdownFile struct { 20 | Text string 21 | Theme string 22 | Html string 23 | Meta map[string]any 24 | MetaHtml string 25 | } 26 | 27 | func NewMarkdownFileFromStr(str string, theme string) (MarkdownFile, error) { 28 | var mdFile MarkdownFile 29 | mdFile.Text = str 30 | mdFile.Theme = theme 31 | md := goldmark.New( 32 | goldmark.WithExtensions( 33 | meta.Meta, 34 | highlighting.NewHighlighting( 35 | highlighting.WithStyle(theme), 36 | highlighting.WithFormatOptions( 37 | chromahtml.WithLineNumbers(true), 38 | ), 39 | ), 40 | ), 41 | goldmark.WithParserOptions( 42 | parser.WithAutoHeadingID(), 43 | ), 44 | goldmark.WithParserOptions( 45 | parser.WithAttribute(), 46 | ), 47 | goldmark.WithRendererOptions( 48 | html.WithHardWraps(), 49 | html.WithXHTML(), 50 | html.WithUnsafe(), 51 | ), 52 | ) 53 | var buf bytes.Buffer 54 | context := parser.NewContext() 55 | if err := md.Convert([]byte(str), &buf, parser.WithContext(context)); err != nil { 56 | return mdFile, err 57 | } 58 | mdFile.Html = buf.String() 59 | mdFile.Meta = meta.Get(context) 60 | mdFile.MetaHtml = "" 61 | for key, value := range mdFile.Meta { 62 | if !strings.HasPrefix(key, "meta") { 63 | continue 64 | } 65 | mdFile.MetaHtml = mdFile.MetaHtml + fmt.Sprintf("\n", key, value) 66 | } 67 | if mdFile.MetaHtml != "" { 68 | mdFile.Html = mdFile.MetaHtml + "" + mdFile.Html 69 | } 70 | return mdFile, nil 71 | } 72 | 73 | func NewMarkdownFileFromPath(path string, theme string) (MarkdownFile, error) { 74 | mdBytes, err := os.ReadFile(path) 75 | if err != nil { 76 | return MarkdownFile{}, err 77 | } 78 | mdFile, err := NewMarkdownFileFromStr(string(mdBytes), theme) 79 | if err != nil { 80 | return MarkdownFile{}, wherr.Consume(wherr.Here(), err, "") 81 | } 82 | return mdFile, nil 83 | } 84 | 85 | func SaveMarkdownHtmlToDisk(mdFile MarkdownFile, saveTo string) error { 86 | err := os.MkdirAll(filepath.Dir(saveTo), 0755) 87 | if err != nil { 88 | return wherr.Consume(wherr.Here(), err, "") 89 | } 90 | err = os.WriteFile(saveTo, []byte(mdFile.Html), 0644) 91 | if err != nil { 92 | return wherr.Consume(wherr.Here(), err, "") 93 | } 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Phillip-England/mood v0.0.1 h1:XtdnBTWl2Vk22pmp26VpvnlGitdIXbXBrE7GggUwcWo= 2 | github.com/Phillip-England/mood v0.0.1/go.mod h1:py37wX8i4ziEUTphSnNJ6j5PxCN/nXM1ZGlrcGO2xOc= 3 | github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= 4 | github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= 5 | github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= 6 | github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 10 | github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 11 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 12 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 13 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 14 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 15 | github.com/phillip-england/mood v0.0.17 h1:FiHxubYetJwYmBAyQZ1BNAfju27QokWACoUo9LqtRqo= 16 | github.com/phillip-england/mood v0.0.17/go.mod h1:cUlmgmDnBWN3uYbqxnhKLIhX4VhIZmaQ8Bp+XZkN5xg= 17 | github.com/phillip-england/wherr v0.0.1 h1:FBPY3a7a5o6GkEkcSyEyCeHCPNg+HeGXoLeL5aVs5sQ= 18 | github.com/phillip-england/wherr v0.0.1/go.mod h1:WFwfUHC6l3+edXnwrdOb6VnXWo7XppiBeDWNOnDh7GU= 19 | github.com/phillip-england/whip v0.0.1 h1:t5MvR1W3vp86LazfsQJkV5/yWSXXFwQKbI2CQv3QcLk= 20 | github.com/phillip-england/whip v0.0.1/go.mod h1:zCUBfC8IqogxgO3vagsJtP1WOW+JkKV7rQOURmSt/v4= 21 | github.com/phillip-england/whip v0.0.2 h1:82XmLDT+n0ljPw0EdX1rM4qlbg4tVtR6+SJdzozVuWE= 22 | github.com/phillip-england/whip v0.0.2/go.mod h1:zCUBfC8IqogxgO3vagsJtP1WOW+JkKV7rQOURmSt/v4= 23 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 24 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 25 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 26 | github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 27 | github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 28 | github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 29 | github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 30 | github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 31 | github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 32 | github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= 33 | github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= 34 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 35 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 36 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 37 | gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= 38 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 39 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 40 | -------------------------------------------------------------------------------- /cmd/Run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | 10 | "github.com/fsnotify/fsnotify" 11 | "github.com/phillip-england/marki/mdfile" 12 | "github.com/phillip-england/wherr" 13 | "github.com/phillip-england/whip" 14 | ) 15 | 16 | type Run struct { 17 | Src string 18 | SrcIsFile bool 19 | Out string 20 | Theme string 21 | HasWatchFlag bool 22 | } 23 | 24 | func NewRun(cli *whip.Cli) (whip.Cmd, error) { 25 | src, err := cli.ArgGetByPositionForce(2, "missing in 'marki convert'") 26 | if err != nil { 27 | return Run{}, wherr.Consume(wherr.Here(), err, "") 28 | } 29 | srcIsFile := whip.IsFile(src) 30 | srcIsDir := whip.IsDir(src) 31 | if !srcIsDir && !srcIsFile { 32 | return Run{}, wherr.Err(wherr.Here(), " in 'marki convert' must be either a file or dir on your system") 33 | } 34 | if srcIsFile { 35 | if !whip.FileExists(src) { 36 | return Run{}, wherr.Err(wherr.Here(), "file %s does not exist", src) 37 | } 38 | } else { 39 | if !whip.DirExists(src) { 40 | return Run{}, wherr.Err(wherr.Here(), "dir %s does not exist", src) 41 | } 42 | } 43 | out, err := cli.ArgGetByPositionForce(3, "missing in 'marki convert'") 44 | if err != nil { 45 | return Run{}, wherr.Consume(wherr.Here(), err, "") 46 | } 47 | if srcIsFile { 48 | if !whip.IsFile(out) { 49 | return Run{}, wherr.Err(wherr.Here(), " must be a properly formatted file path if is a file") 50 | } 51 | } else { 52 | if !whip.IsDir(out) { 53 | return Run{}, wherr.Err(wherr.Here(), " must be a properly formatted dir path if is a dir") 54 | } 55 | } 56 | theme, err := cli.ArgGetByPositionForce(4, " ") 57 | if err != nil { 58 | theme = "dracula" 59 | } 60 | err = isValidTheme(theme) 61 | if err != nil { 62 | return Run{}, wherr.Consume(wherr.Here(), err, "") 63 | } 64 | hasWatchFlag := cli.FlagExists("--watch") 65 | return Run{ 66 | Src: src, 67 | SrcIsFile: srcIsFile, 68 | Out: out, 69 | Theme: theme, 70 | HasWatchFlag: hasWatchFlag, 71 | }, nil 72 | } 73 | 74 | func (cmd Run) Execute(app *whip.Cli) error { 75 | if cmd.SrcIsFile { 76 | err := handleFile(cmd, app) 77 | if err != nil { 78 | return wherr.Consume(wherr.Here(), err, "") 79 | } 80 | return nil 81 | } 82 | err := handleDir(cmd, app) 83 | if err != nil { 84 | return wherr.Consume(wherr.Here(), err, "") 85 | } 86 | return nil 87 | } 88 | 89 | func handleFile(cmd Run, app *whip.Cli) error { 90 | mdFile, err := mdfile.NewMarkdownFileFromPath(cmd.Src, cmd.Theme) 91 | if err != nil { 92 | return wherr.Consume(wherr.Here(), err, "") 93 | } 94 | err = mdfile.SaveMarkdownHtmlToDisk(mdFile, cmd.Out) 95 | if err != nil { 96 | return wherr.Consume(wherr.Here(), err, "") 97 | } 98 | if !cmd.HasWatchFlag { 99 | return nil 100 | } 101 | watcher, err := fsnotify.NewWatcher() 102 | if err != nil { 103 | return wherr.Consume(wherr.Here(), err, "") 104 | } 105 | defer watcher.Close() 106 | err = watcher.Add(cmd.Src) 107 | if err != nil { 108 | return wherr.Consume(wherr.Here(), err, "") 109 | } 110 | errCh := make(chan error) 111 | fmt.Printf("👁️: watching %s\n", cmd.Src) 112 | triggerCh := make(chan bool) 113 | 114 | go func() { 115 | var timer *time.Timer 116 | for { 117 | select { 118 | case event, ok := <-watcher.Events: 119 | if !ok { 120 | return 121 | } 122 | if event.Op&fsnotify.Write == fsnotify.Write { 123 | if timer != nil { 124 | timer.Stop() 125 | } 126 | timer = time.AfterFunc(100*time.Millisecond, func() { 127 | triggerCh <- true 128 | }) 129 | } 130 | case err, ok := <-watcher.Errors: 131 | if !ok { 132 | return 133 | } 134 | fmt.Printf("🚨: watcher error: %s\n", err.Error()) 135 | case <-triggerCh: 136 | fmt.Printf("📝: writing to %s\n", cmd.Out) 137 | if err := convertAndSaveFile(cmd.Src, cmd.Theme, cmd.Out); err != nil { 138 | errCh <- err 139 | return 140 | } 141 | } 142 | } 143 | }() 144 | select { 145 | case err := <-errCh: 146 | return err 147 | case <-make(chan struct{}): 148 | fmt.Println("👋 bye-bye!") 149 | } 150 | return nil 151 | } 152 | 153 | func handleDir(cmd Run, app *whip.Cli) error { 154 | err := convertAndSaveDir(cmd.Src, cmd.Out, cmd.Theme) 155 | if err != nil { 156 | return wherr.Consume(wherr.Here(), err, "") 157 | } 158 | if !cmd.HasWatchFlag { 159 | return nil 160 | } 161 | watcher, err := fsnotify.NewWatcher() 162 | if err != nil { 163 | return wherr.Consume(wherr.Here(), err, "") 164 | } 165 | defer watcher.Close() 166 | err = watcher.Add(cmd.Src) 167 | if err != nil { 168 | return wherr.Consume(wherr.Here(), err, "") 169 | } 170 | 171 | err = filepath.WalkDir(cmd.Src, func(path string, d fs.DirEntry, err error) error { 172 | if err != nil { 173 | return wherr.Consume(wherr.Here(), err, "") 174 | } 175 | if filepath.Ext(path) != ".md" { 176 | return nil 177 | } 178 | err = watcher.Add(path) 179 | if err != nil { 180 | return wherr.Consume(wherr.Here(), err, "") 181 | } 182 | return nil 183 | }) 184 | if err != nil { 185 | return wherr.Consume(wherr.Here(), err, "") 186 | } 187 | errCh := make(chan error) 188 | fmt.Printf("👁️: watching %s\n", cmd.Src) 189 | triggerCh := make(chan bool) 190 | go func() { 191 | var timer *time.Timer 192 | for { 193 | select { 194 | case event, ok := <-watcher.Events: 195 | if !ok { 196 | return 197 | } 198 | if event.Op&fsnotify.Write == fsnotify.Write { 199 | if timer != nil { 200 | timer.Stop() 201 | } 202 | timer = time.AfterFunc(100*time.Millisecond, func() { 203 | triggerCh <- true 204 | }) 205 | } 206 | case err, ok := <-watcher.Errors: 207 | if !ok { 208 | return 209 | } 210 | fmt.Printf("🚨: watcher error: %s\n", err.Error()) 211 | case <-triggerCh: 212 | fmt.Printf("📝: writing to %s\n", cmd.Out) 213 | if err := convertAndSaveDir(cmd.Src, cmd.Out, cmd.Theme); err != nil { 214 | errCh <- err 215 | return 216 | } 217 | } 218 | } 219 | }() 220 | select { 221 | case err := <-errCh: 222 | return err 223 | case <-make(chan struct{}): 224 | fmt.Println("👋 bye-bye!") 225 | } 226 | return nil 227 | } 228 | 229 | func convertAndSaveDir(inDir string, outDir string, theme string) error { 230 | err := filepath.WalkDir(inDir, func(path string, d fs.DirEntry, err error) error { 231 | if err != nil { 232 | return wherr.Consume(wherr.Here(), err, "") 233 | } 234 | if d.IsDir() { 235 | return nil 236 | } 237 | if filepath.Ext(path) != ".md" { 238 | return nil 239 | } 240 | relPath, err := filepath.Rel(inDir, path) 241 | if err != nil { 242 | return wherr.Consume(wherr.Here(), err, "") 243 | } 244 | outPath := filepath.Join(outDir, relPath) 245 | outPath = strings.TrimSuffix(outPath, ".md") 246 | outPath = outPath + ".html" 247 | err = convertAndSaveFile(path, theme, outPath) 248 | if err != nil { 249 | return wherr.Consume(wherr.Here(), err, "") 250 | } 251 | return nil 252 | }) 253 | if err != nil { 254 | return wherr.Consume(wherr.Here(), err, "") 255 | } 256 | return nil 257 | } 258 | 259 | func convertAndSaveFile(mdFilePath string, theme string, saveTo string) error { 260 | mdFile, err := mdfile.NewMarkdownFileFromPath(mdFilePath, theme) 261 | if err != nil { 262 | return wherr.Consume(wherr.Here(), err, "failed to load markdown") 263 | } 264 | err = mdfile.SaveMarkdownHtmlToDisk(mdFile, saveTo) 265 | if err != nil { 266 | return wherr.Consume(wherr.Here(), err, "failed to save html") 267 | } 268 | return nil 269 | } 270 | --------------------------------------------------------------------------------