├── .gitignore ├── logo.png ├── examples └── blog │ ├── routes │ ├── about.md │ ├── posts │ │ ├── third_post.md │ │ ├── second_post.md │ │ ├── first_post.md │ │ └── index.html │ ├── [tag].html │ ├── contact.html │ ├── css │ │ └── styles.css │ ├── index.html │ └── other.html │ ├── templates │ └── default.html │ ├── partials │ ├── head.html │ └── navbar.html │ └── output │ ├── css │ └── styles.css │ ├── about │ └── index.html │ ├── contact │ └── index.html │ ├── posts │ ├── third_post │ │ └── index.html │ ├── second_post │ │ └── index.html │ ├── first_post │ │ └── index.html │ └── index.html │ ├── other │ └── index.html │ └── index.html ├── Makefile ├── LICENSE.txt ├── go.mod ├── main.go ├── go.sum ├── README.md └── route.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .DS_Store 3 | dist 4 | tinystatic 5 | output/ -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/julvo/tinystatic/HEAD/logo.png -------------------------------------------------------------------------------- /examples/blog/routes/about.md: -------------------------------------------------------------------------------- 1 | --- 2 | template: default.html 3 | title: About 4 | --- 5 | # About Us 6 | 7 | Here could be an about section 8 | -------------------------------------------------------------------------------- /examples/blog/routes/posts/third_post.md: -------------------------------------------------------------------------------- 1 | --- 2 | template: default.html 3 | title: Third Post 4 | date: 2020-03-20 5 | tags: 6 | - React 7 | --- 8 | 9 | # This is the third post 10 | 11 | ```javascript 12 | const thirdFunc = () => console.log('highlighted') 13 | ``` -------------------------------------------------------------------------------- /examples/blog/routes/posts/second_post.md: -------------------------------------------------------------------------------- 1 | --- 2 | template: default.html 3 | title: Second Post 4 | date: 2020-03-19 5 | tags: 6 | - Python 7 | --- 8 | 9 | # This is the second post 10 | 11 | ```golang 12 | func secondFunc() { 13 | fmt.Println("highlighted") 14 | } 15 | ``` -------------------------------------------------------------------------------- /examples/blog/routes/[tag].html: -------------------------------------------------------------------------------- 1 | --- 2 | template: default.html 3 | tag: | 4 | {{ .Routes | fn "files => Array.from(files.reduce((tags, file) => {file.Meta?.tags?.forEach(tag => tags.add(tag.toLowerCase())); return tags}, new Set()))" }} 5 | --- 6 | {{define "body"}} 7 | Hello {{.Route.Meta.tag | title}} 8 | {{end}} -------------------------------------------------------------------------------- /examples/blog/templates/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{template "head" .}} 5 | 6 | 7 |
8 | {{template "navbar" .}} 9 | {{template "body" .}} 10 |
11 | 12 | -------------------------------------------------------------------------------- /examples/blog/routes/posts/first_post.md: -------------------------------------------------------------------------------- 1 | --- 2 | template: default.html 3 | title: First Post 4 | date: 2020-03-18 5 | tags: 6 | - Javascript 7 | - React 8 | --- 9 | 10 | # This is the first post 11 | 12 | Here could be some content 13 | 14 | ```python 15 | def first_func(): 16 | print('highlighted') 17 | ``` 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: dist/tinystatic_macos_darwin_amd64 dist/tinystatic_linux_amd64 2 | 3 | dist/tinystatic_macos_darwin_amd64: *.go 4 | GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o dist/tinystatic_macos_darwin_amd64 5 | 6 | dist/tinystatic_linux_amd64: *.go 7 | GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o dist/tinystatic_linux_amd64 8 | 9 | -------------------------------------------------------------------------------- /examples/blog/routes/posts/index.html: -------------------------------------------------------------------------------- 1 | --- 2 | template: default.html 3 | --- 4 | 5 | {{define "body"}} 6 |

Posts

7 | 8 | 17 | 18 | {{end}} -------------------------------------------------------------------------------- /examples/blog/routes/contact.html: -------------------------------------------------------------------------------- 1 | --- 2 | template: default.html 3 | title: Contact 4 | --- 5 | 6 | {{define "body"}} 7 |

Contact Us

8 |
9 |

10 | 11 |

12 |

13 | 14 |

15 | 16 |
17 | {{end}} 18 | -------------------------------------------------------------------------------- /examples/blog/partials/head.html: -------------------------------------------------------------------------------- 1 | {{ define "head"}} 2 | 3 | 4 | {{if .title}} {{.title}} | {{end}}fineblog.com 5 | 6 | 7 | 8 | 9 | 10 | {{end}} -------------------------------------------------------------------------------- /examples/blog/partials/navbar.html: -------------------------------------------------------------------------------- 1 | {{define "navbar"}} 2 | 21 | {{end}} 22 | -------------------------------------------------------------------------------- /examples/blog/output/css/styles.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | padding: 0; 3 | margin: 0; 4 | font-family: BlinkMacSystemFont, -apple-system, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Helvetica", "Arial", sans-serif !important; 5 | } 6 | 7 | pre { 8 | padding: 12px; 9 | } 10 | 11 | .container { 12 | max-width: 80rem; 13 | margin: 0 auto; 14 | } 15 | 16 | .navbar { 17 | border-bottom: 1px solid #888; 18 | } 19 | 20 | .navbar>ul { 21 | list-style-type: none; 22 | margin: 0; 23 | padding: 0; 24 | display: flex; 25 | flex-direction: row; 26 | } 27 | 28 | .navbar>ul>li { 29 | padding: 16px; 30 | } 31 | 32 | .brand>a { 33 | color: black; 34 | text-decoration: none; 35 | font-weight: bold; 36 | } -------------------------------------------------------------------------------- /examples/blog/routes/css/styles.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | padding: 0; 3 | margin: 0; 4 | font-family: BlinkMacSystemFont, -apple-system, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Helvetica", "Arial", sans-serif !important; 5 | } 6 | 7 | pre { 8 | padding: 12px; 9 | } 10 | 11 | .container { 12 | max-width: 80rem; 13 | margin: 0 auto; 14 | } 15 | 16 | .navbar { 17 | border-bottom: 1px solid #888; 18 | } 19 | 20 | .navbar>ul { 21 | list-style-type: none; 22 | margin: 0; 23 | padding: 0; 24 | display: flex; 25 | flex-direction: row; 26 | } 27 | 28 | .navbar>ul>li { 29 | padding: 16px; 30 | } 31 | 32 | .brand>a { 33 | color: black; 34 | text-decoration: none; 35 | font-weight: bold; 36 | } -------------------------------------------------------------------------------- /examples/blog/routes/index.html: -------------------------------------------------------------------------------- 1 | --- 2 | template: default.html 3 | --- 4 | 5 | {{define "body"}} 6 |

7 | Welcome to this fine blog 8 |

9 | 10 |
11 | 20 |
21 | 22 |

23 | This is an example of a typical website structure for a blog. 24 |

25 | 26 |

27 | Here is the latest post 28 |

29 | 30 | {{with index (.Routes | filterFilePath "**/posts/*.md" | sortDesc "date") 0}} 31 | {{.Meta.title}} 32 | {{end}} 33 | 34 | {{end}} -------------------------------------------------------------------------------- /examples/blog/routes/other.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{template "head" .}} 5 | 6 | 7 | 8 |
9 | <- Home 10 | 11 |

12 | This is some other route 13 |

14 |

15 | In this route, we are not using a template, but we have still access to partials and the render context, e.g. `.Routes`. 16 |

17 | 18 | 19 |

20 | A list of posts: 21 |

22 | 30 |
31 | 32 | -------------------------------------------------------------------------------- /examples/blog/output/about/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | About | fineblog.com 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 37 | 38 |

About Us

39 |

Here could be an about section

40 | 41 |
42 | 43 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 2019 Julian Vossen 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | The above copyright notice and this permission notice shall be included in all 10 | copies or substantial portions of the Software. 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/julvo/tinystatic 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/yuin/goldmark v1.4.4 7 | github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01 8 | github.com/yuin/goldmark-meta v1.0.0 9 | gopkg.in/yaml.v2 v2.4.0 10 | ) 11 | 12 | require ( 13 | github.com/Masterminds/goutils v1.1.1 // indirect 14 | github.com/Masterminds/semver/v3 v3.1.1 // indirect 15 | github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91 // indirect 16 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect 17 | github.com/google/uuid v1.1.1 // indirect 18 | github.com/huandu/xstrings v1.3.1 // indirect 19 | github.com/imdario/mergo v0.3.11 // indirect 20 | github.com/mitchellh/copystructure v1.0.0 // indirect 21 | github.com/mitchellh/reflectwalk v1.0.0 // indirect 22 | github.com/shopspring/decimal v1.2.0 // indirect 23 | github.com/spf13/cast v1.3.1 // indirect 24 | golang.org/x/crypto v0.0.0-20200414173820-0848c9571904 // indirect 25 | golang.org/x/text v0.3.7 // indirect 26 | ) 27 | 28 | require ( 29 | github.com/Masterminds/sprig/v3 v3.2.2 30 | github.com/alecthomas/chroma v0.9.4 // indirect 31 | github.com/dop251/goja v0.0.0-20220705101429-189bfeb9f530 32 | ) 33 | -------------------------------------------------------------------------------- /examples/blog/output/contact/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Contact | fineblog.com 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 37 | 38 | 39 |

Contact Us

40 |
41 |

42 | 43 |

44 |

45 | 46 |

47 | 48 |
49 | 50 |
51 | 52 | -------------------------------------------------------------------------------- /examples/blog/output/posts/third_post/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Third Post | fineblog.com 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 37 | 38 |

This is the third post

39 |
const thirdFunc = () => console.log('highlighted')
40 | 
41 |
42 | 43 | -------------------------------------------------------------------------------- /examples/blog/output/other/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | fineblog.com 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | <- Home 19 | 20 |

21 | This is some other route 22 |

23 |

24 | In this route, we are not using a template, but we have still access to partials and the render context, e.g. `.Routes`. 25 |

26 | 27 | 28 |

29 | A list of posts: 30 |

31 | 49 |
50 | 51 | -------------------------------------------------------------------------------- /examples/blog/output/posts/second_post/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Second Post | fineblog.com 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 37 | 38 |

This is the second post

39 |
func secondFunc() {
40 |     fmt.Println("highlighted")
41 | }
42 | 
43 |
44 | 45 | -------------------------------------------------------------------------------- /examples/blog/output/posts/first_post/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | First Post | fineblog.com 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 37 | 38 |

This is the first post

39 |

Here could be some content

40 |
def first_func():
41 |     print('highlighted')
42 | 
43 |
44 | 45 | -------------------------------------------------------------------------------- /examples/blog/output/posts/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | fineblog.com 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 37 | 38 | 39 |

Posts

40 | 41 | 62 | 63 | 64 |
65 | 66 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | ) 8 | 9 | var ( 10 | partialDir string 11 | templateDir string 12 | ) 13 | 14 | func main() { 15 | var outputDir string 16 | var routeDir string 17 | var clean bool 18 | flag.StringVar(&outputDir, "output", "./output", "The directory to write the generated outputs to") 19 | flag.StringVar(&routeDir, "routes", "./routes", "The directory from which to read the routes") 20 | flag.StringVar(&partialDir, "partials", "./partials", "The directory from which to read the partials") 21 | flag.StringVar(&templateDir, "templates", "./templates", "The directory from which to read the templates") 22 | flag.BoolVar(&clean, "clean", false, "Whether to delete the output directory before regenerating") 23 | flag.Parse() 24 | 25 | if clean { 26 | log.Println("Removing previous output from", outputDir) 27 | if err := os.RemoveAll(outputDir); err != nil { 28 | log.Fatalln(err) 29 | } 30 | } 31 | 32 | log.Println("Loading routes from", routeDir) 33 | rootRoute, err := LoadRoutes("/", routeDir) 34 | if err != nil { 35 | log.Fatalln(err) 36 | } 37 | 38 | if err := ExpandRoutes(&rootRoute); err != nil { 39 | log.Fatalln(err) 40 | } 41 | 42 | log.Println("Writing output to", outputDir) 43 | allRoutes := rootRoute.AllRoutes() 44 | for _, r := range allRoutes { 45 | if r.Href != "" { 46 | log.Println("∟", r.FilePath, "->", r.Href) 47 | } 48 | if err := r.Generate(outputDir, allRoutes); err != nil { 49 | log.Fatalln(err) 50 | } 51 | } 52 | log.Println("Finished") 53 | } 54 | -------------------------------------------------------------------------------- /examples/blog/output/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | fineblog.com 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 37 | 38 | 39 |

40 | Welcome to this fine blog 41 |

42 | 43 |
44 | 65 |
66 | 67 |

68 | This is an example of a typical website structure for a blog. 69 |

70 | 71 |

72 | Here is the latest post 73 |

74 | 75 | 76 | Third Post 77 | 78 | 79 | 80 |
81 | 82 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 2 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 3 | github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= 4 | github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= 5 | github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8= 6 | github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= 7 | github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= 8 | github.com/alecthomas/chroma v0.7.2-0.20200305040604-4f3623dce67a/go.mod h1:fv5SzZPFJbwp2NXJWpFIX7DZS4HgV1K4ew4Pc2OZD9s= 9 | github.com/alecthomas/chroma v0.9.4 h1:YL7sOAE3p8HS96T9km7RgvmsZIctqbK1qJ0b7hzed44= 10 | github.com/alecthomas/chroma v0.9.4/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= 11 | github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= 12 | github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= 13 | github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= 14 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 15 | github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= 16 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 18 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 20 | github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 21 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 22 | github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91 h1:Izz0+t1Z5nI16/II7vuEo/nHjodOg0p7+OiDpjX5t1E= 23 | github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 24 | github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= 25 | github.com/dop251/goja v0.0.0-20220705101429-189bfeb9f530 h1:936YSsrki8Z6H48PPFbATV674Gpmh444xXaX+O5wwFQ= 26 | github.com/dop251/goja v0.0.0-20220705101429-189bfeb9f530/go.mod h1:TQJQ+ZNyFVvUtUEtCZxBhfWiH7RJqR3EivNmvD6Waik= 27 | github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= 28 | github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= 29 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= 30 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= 31 | github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= 32 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 33 | github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs= 34 | github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 35 | github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= 36 | github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= 37 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 38 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 39 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 40 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 41 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 42 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 43 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 44 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 45 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 46 | github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 47 | github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= 48 | github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= 49 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 50 | github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= 51 | github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 52 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 53 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 54 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 55 | github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= 56 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 57 | github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 58 | github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= 59 | github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 60 | github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= 61 | github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 62 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 63 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 64 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 65 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 66 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 67 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 68 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 69 | github.com/yuin/goldmark v1.3.6/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 70 | github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs= 71 | github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= 72 | github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01 h1:0SJnXjE4jDClMW6grE0xpNhwpqbPwkBTn8zpVw5C0SI= 73 | github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01/go.mod h1:TwKQPa5XkCCRC2GRZ5wtfNUTQ2+9/i19mGRijFeJ4BE= 74 | github.com/yuin/goldmark-meta v1.0.0 h1:ScsatUIT2gFS6azqzLGUjgOnELsBOxMXerM3ogdJhAM= 75 | github.com/yuin/goldmark-meta v1.0.0/go.mod h1:zsNNOrZ4nLuyHAJeLQEZcQat8dm70SmB2kHbls092Gc= 76 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 77 | golang.org/x/crypto v0.0.0-20200414173820-0848c9571904 h1:bXoxMPcSLOq08zI3/c5dEBT6lE4eh+jOh886GHrn6V8= 78 | golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 79 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 80 | golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 81 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 82 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 83 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 84 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 85 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 86 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 87 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 88 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 89 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 90 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 91 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 92 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 93 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 94 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 95 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 96 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 97 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 98 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tinystatic 2 | 3 | ![logo.png](https://github.com/julvo/tinystatic/blob/master/logo.png "tinystatic logo") 4 | 5 | A tiny static website generator that is flexible and easy to use. It's flexible, as there is no required website structure nor any blog-specific concepts. It's easy to use, as we can start with a standard HTML site and introduce tinystatic gradually. 6 | 7 | The concept of tinystatic is simple: From every file in an input directory, create a file in an output directory which we can then use as the public directory of our webserver. How tinystatic generates an output file depends on the input file extension: Markdown is converted to HTML, while CSS, JS and images are simply copied. For markdown and HTML files, you can specify meta data at the top of a file. By specifying a template in this file meta data, and providing templates in separate directories, you can make use of Go's HTML templating engine. [Here](https://github.com/julvo/tinystatic/tree/master/examples/blog) an example of a typical blog website and for a quick start guide, see below. 8 | 9 | ## Install 10 | 11 | ### Pre-built binaries 12 | Download the tinystatic binary for your operating system: 13 | - [Linux](https://github.com/julvo/tinystatic/releases/download/v0.1.0/tinystatic_linux_amd64) 14 | - [macOS](https://github.com/julvo/tinystatic/releases/download/v0.1.0/tinystatic_macos_darwin_amd64) 15 | 16 | Optionally, add the binary to your shell path, by either placing the binary into an existing directory like `/usr/bin` or by adding the parent directory of the binary to your path variable. 17 | 18 | If you added tinystatic to your path, you should be able to call 19 | ```shell 20 | tinystatic -help 21 | ``` 22 | Otherwise, you will need to specify the path to the tinystatic binary when calling it 23 | ```shell 24 | /path/to/tinystatic -help 25 | ``` 26 | 27 | ### Compiling from source 28 | If you don't want to use the pre-built binaries, you will need to install the Golang compiler to compile tinystatic. Then, you can install tinystatic by running 29 | ```shell 30 | go install -u github.com/julvo/tinystatic 31 | ``` 32 | or by cloning the repository and running `go install` or `go build` in the root directory of this repository. 33 | 34 | ## Quick start 35 | This is a 10-minute tutorial in which we build a small blog, starting with a single HTML page and introducing the features of tinystatic one-by-one. 36 | 37 | First, we create a folder called `routes`. Inside this folder, we create a single HTML file `index.html` with the following contents: 38 | ```html 39 | 40 | 41 | 42 | Our first tinystatic website 43 | 44 | 45 |

Welcome to our blog

46 | 47 | 48 | ``` 49 | 50 | Now, we can run `tinystatic` for the first time. By default, tinystatic expects to be called in the directory containing the `routes` directory, but you can change that by using the `-routes` parameter. After running the command, you should see a folder `output` appearing next to the `routes` folder. Our file structure now looks like this: 51 | ``` 52 | my-blog/ 53 | routes/ 54 | index.html 55 | output/ 56 | index.html 57 | ``` 58 | We can now run a webserver in the output directory, e.g. using Python's built-in server to open our website on `http://localhost:8000`: 59 | ``` 60 | cd output 61 | python3 -m http.server 62 | ``` 63 | 64 | So far, all tinystatic did was copying the `index.html` from `routes` to `output` - not all that useful, but hang in... 65 | 66 | Let's add a second HTML file to `routes`, e.g. `about.html`: 67 | ```html 68 | 69 | 70 | 71 | Our first tinystatic website 72 | 73 | 74 |

About us

75 | 76 | 77 | ``` 78 | 79 | After we run `tinystatic` again, and with our webserver still running, we can now navigate to `http://localhost:8000/about`. Note how there is no `.html` in this route anymore, as tinystatic created a folder `about` with a single `index.html` in it, like so: 80 | ``` 81 | output/ 82 | index.html 83 | about/ 84 | index.html 85 | ``` 86 | 87 | What we don't like about our current pages is the duplication of all the basic HTML structure. Wouldn't it be better to use a shared template for `index.html` and `about.html`?. To do this, we create a folder called `templates` next to our `routes` folder and place an HTML file `default.html` inside it: 88 | 89 | ``` 90 | my-blog/ 91 | routes/ 92 | index.html 93 | about.html 94 | templates/ 95 | default.html 96 | ``` 97 | The content of `default.html` should be: 98 | ```html 99 | 100 | 101 | 102 | Our first tinystatic website 103 | 104 | 105 | {{template "body" .}} 106 | 107 | 108 | ``` 109 | 110 | Also, we change the content of `routes/index.html` to 111 | ```html 112 | --- 113 | template: default.html 114 | --- 115 | {{define "body"}} 116 |

Welcome to our blog

117 | {{end}} 118 | ``` 119 | 120 | and the content of `routes/about.html` to 121 | ```html 122 | --- 123 | template: default.html 124 | --- 125 | {{define "body"}} 126 |

About us

127 | {{end}} 128 | ``` 129 | 130 | When running `tinystatic` again, the output is identical to the previous output, but we consolidated the HTML skeleton into a single place. 131 | 132 | As seen just now, we can specify a template to render our content into by providing a template name in the meta data at the top of a file. We can also include other templates (see below) and use Go's template pipelines. While rendering, we have access to the meta data defined at the top of the file, a struct `Route` with fields `Route.Href`, `Route.FilePath` and `Route.Meta` which is again a map of meta data defined at the top of the file. Moreover, we can access `Routes`, which is a slice (think: array for people new to Go) of all routes, which we will learn more about further down. 133 | 134 | Let's use this meta data together with Go's templating primitives to change the page title depending on the current page. For this, we change the meta data in `routes/about.html` to 135 | ``` 136 | --- 137 | template: default.html 138 | title: About 139 | --- 140 | ``` 141 | 142 | and finally change `templates/default.html` to 143 | ```html 144 | 145 | 146 | 147 | {{if .title}} {{.title}} | {{end}}Our first tinystatic website 148 | 149 | 150 | {{template "body" .}} 151 | 152 | 153 | ``` 154 | 155 | After regenerating the website, the browser should now display different page titles for our index and our about page. 156 | 157 | Now, let's create a few blog posts in our routes folder, e.g. 158 | ``` 159 | routes/ 160 | index.html 161 | about.html 162 | posts/ 163 | first_post.md 164 | second_post.md 165 | ``` 166 | 167 | Place some markdown inside these `.md` files with a meta data section at the top specifying the template as `default.html`, similar to how we specified the meta data in `routes/index.html` and `routes/about.html`. For `first_post.md`, this could look like this: 168 | ```markdown 169 | --- 170 | template: default.html 171 | title: First Post 172 | --- 173 | 174 | # Here could be some fine content 175 | 176 | ``` 177 | 178 | 179 | Running `tinystatic` again to regenerate the output, we can now visit `http://localhost:8000/posts/first_post` and `http://localhost:8000/posts/second_post`. The markdown has been converted HTML and placed inside a template called `body` for us, so that it renders into the `{{template "body" .}}` placeholder in `templates/default.html`. Note how this is different to `.html` files, where we need to call `{{define "body"}} ... {{end}}` manually. 180 | 181 | Next, let's create a listing of our posts by using the aforementioned `Routes` slice. We change the content of `routes/index.html` to: 182 | ```html 183 | --- 184 | template: default.html 185 | --- 186 | {{define "body"}} 187 |

Welcome to our blog

188 | 189 | 196 | ``` 197 | 198 | After regenerating, we should see a list of our posts on the index page. The `Routes` slice provides a list of all routes which we can filter using pre-defined helper functions, e.g. 199 | - `.Routes | filterFilePath "**/posts/*.md"` to display all files ending on `.md` in any folder called posts 200 | - `.Routes | sortAsc "title"` to sort routes based on the meta data field `title` 201 | - `.Routes | limit 10` to get only the first 10 routes 202 | - `.Routes | offset 3` to skip the first three routes 203 | - `.Routes | filter "title" "*Post"` to filter based on the meta data field `title` matching the pattern `*Post` 204 | - `.Routes | filterFileName "*.md"` to get all files ending on `*.md` 205 | - `.Routes | filterHref "/*"` to get all top-level routes 206 | - `.Routes | filterFilePath "**/posts/*.md" | sortDesc "title" | limit 10` to combine some of the above 207 | 208 | Next, we would like to use a different layout for posts than for the other pages. The posts should have an image before the text, whereby we want to define the image URL in the post meta data. Therefore, we add a second template called `templates/post.html` with the following content: 209 | 210 | ```html 211 | 212 | 213 | 214 | {{if .title}} {{.title}} | {{end}}Our first tinystatic website 215 | 216 | 217 | 218 | {{template "body" .}} 219 | 220 | 221 | ``` 222 | 223 | We change the post meta data to 224 | ``` 225 | --- 226 | template: post.html 227 | title: First Post 228 | image: https://some-image.url 229 | --- 230 | ``` 231 | Regenerating the output should give us a beautiful image above our post. However, we also ended up with duplicated HTML code in our templates again. To improve that, we create another folder next to `routes` and `templates` called `partials`. Inside partials, we create a file called `head.html` with 232 | ```html 233 | {{define "head"}} 234 | 235 | {{if .title}} {{.title}} | {{end}}Our first tinystatic website 236 | 237 | {{end}} 238 | ``` 239 | and we replace `...` in our templates with `{{template "head" .}}`, like so 240 | ```html 241 | 242 | 243 | {{template "head" .}} 244 | 245 | {{template "body" .}} 246 | 247 | 248 | ``` 249 | Now we reduced the code replication between different templates to a minimum. We can use this `partials` directory to store all kinds of reoccuring components, e.g. navigation bars or footers. 250 | 251 | Note that we don't actually need to structure the project using the folder names that we used in this tutorial. These folders names are merely the defaults, but can be changed using the respective command line arguments (see `tinystatic -help` for more). 252 | 253 | There is a full example of a blog [here](https://github.com/julvo/tinystatic/tree/master/examples/blog). 254 | -------------------------------------------------------------------------------- /route.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "html/template" 8 | "io/ioutil" 9 | "os" 10 | "path/filepath" 11 | "reflect" 12 | "regexp" 13 | "sort" 14 | "strings" 15 | 16 | "github.com/Masterminds/sprig/v3" 17 | "github.com/dop251/goja" 18 | "github.com/yuin/goldmark" 19 | highlighting "github.com/yuin/goldmark-highlighting" 20 | meta "github.com/yuin/goldmark-meta" 21 | "github.com/yuin/goldmark/extension" 22 | "github.com/yuin/goldmark/renderer/html" 23 | "gopkg.in/yaml.v2" 24 | ) 25 | 26 | type Route struct { 27 | Children []Route 28 | FilePath string 29 | Href string 30 | Meta map[string]interface{} 31 | 32 | rawHref string 33 | rawMeta map[string]interface{} 34 | } 35 | 36 | var funcMap map[string]interface{} 37 | 38 | func init() { 39 | funcMap = sprig.FuncMap() 40 | 41 | for k, v := range map[string]interface{}{ 42 | "sortAsc": sortAsc, 43 | "sortDesc": sortDesc, 44 | "limit": limit, 45 | "offset": offset, 46 | "filter": filter, 47 | "filterHref": filterHref, 48 | "filterFileName": filterFileName, 49 | "filterFilePath": filterFilePath, 50 | "fn": fn, 51 | "toUnescapedJson": toUnescapedJson, 52 | } { 53 | funcMap[k] = v 54 | } 55 | } 56 | 57 | func (r *Route) Generate(outputDir string, allRoutes []Route) error { 58 | if r.Href == "" { 59 | return nil 60 | } 61 | 62 | src, err := ioutil.ReadFile(r.FilePath) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | switch ext := strings.ToLower(filepath.Ext(r.FilePath)); ext { 68 | case ".md", ".markdown": 69 | html, err := markdownToHTML(src) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | if _, useTmpl := r.Meta["template"]; useTmpl { 75 | html = []byte(`{{define "body"}}` + string(html) + `{{end}}`) 76 | } 77 | 78 | src = html 79 | fallthrough 80 | 81 | case ".html", ".htm": 82 | if err := os.MkdirAll(filepath.Join(outputDir, r.Href), os.ModePerm); err != nil { 83 | return err 84 | } 85 | 86 | dstPath := filepath.Join(outputDir, r.Href, "index.html") 87 | dstFile, err := os.Create(dstPath) 88 | if err != nil { 89 | return err 90 | } 91 | defer dstFile.Close() 92 | 93 | tmplName, useTmpl := r.Meta["template"] 94 | partials, err := filepath.Glob(filepath.Join(partialDir, "*.html")) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | var tmplFiles []string 100 | var tmplPath string 101 | if useTmpl { 102 | tmplPath = filepath.Join(templateDir, fmt.Sprint(tmplName)) 103 | tmplFiles = append([]string{tmplPath}, partials...) 104 | } else { 105 | tmplPath = r.FilePath 106 | tmplFiles = partials 107 | } 108 | 109 | tmpl := template.New(filepath.Base(tmplPath)) 110 | tmpl = tmpl.Funcs(funcMap) 111 | 112 | if len(tmplFiles) > 0 { 113 | tmpl, err = tmpl.ParseFiles(tmplFiles...) 114 | if err != nil { 115 | return err 116 | } 117 | } 118 | 119 | if strings.HasPrefix(string(src), "---") { 120 | src = []byte(strings.SplitN(string(src), "---", 3)[2]) 121 | } 122 | 123 | tmpl, err = tmpl.Parse(string(src)) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | tmplCtx := map[string]interface{}{} 129 | for k, v := range r.Meta { 130 | tmplCtx[k] = v 131 | } 132 | tmplCtx["Route"] = r 133 | tmplCtx["Routes"] = allRoutes 134 | 135 | if err := tmpl.Execute(dstFile, tmplCtx); err != nil { 136 | return err 137 | } 138 | 139 | default: 140 | if err := os.MkdirAll(filepath.Join(outputDir, filepath.Dir(r.Href)), os.ModePerm); err != nil { 141 | return err 142 | } 143 | if err := ioutil.WriteFile(filepath.Join(outputDir, r.Href), src, os.ModePerm); err != nil { 144 | return err 145 | } 146 | } 147 | 148 | return nil 149 | } 150 | 151 | func (r Route) AllChildren() []Route { 152 | allChildren := []Route{} 153 | for _, c := range r.Children { 154 | allChildren = append(append(allChildren, c), c.AllChildren()...) 155 | } 156 | return allChildren 157 | } 158 | 159 | func (r Route) AllRoutes() []Route { 160 | return append([]Route{r}, r.AllChildren()...) 161 | } 162 | 163 | func LoadRoutes(relPath, baseDir string) (Route, error) { 164 | fullPath := filepath.Join(baseDir, relPath) 165 | fileInfo, err := os.Stat(fullPath) 166 | if err != nil { 167 | return Route{}, err 168 | } 169 | 170 | route := Route{} 171 | if fileInfo.IsDir() { 172 | fileInfos, err := ioutil.ReadDir(fullPath) 173 | if err != nil { 174 | return Route{}, err 175 | } 176 | 177 | for _, child := range fileInfos { 178 | if isIndexFile(child) { 179 | route.rawHref = relPath 180 | route.FilePath = filepath.Join(fullPath, child.Name()) 181 | } else { 182 | childRoute, err := LoadRoutes(filepath.Join(relPath, child.Name()), baseDir) 183 | if err != nil { 184 | return route, err 185 | } 186 | route.Children = append(route.Children, childRoute) 187 | } 188 | } 189 | 190 | } else { 191 | route.rawHref = filePathToHref(relPath) 192 | route.FilePath = fullPath 193 | } 194 | 195 | route.Href = route.rawHref 196 | 197 | if route.FilePath != "" { 198 | fileContent, err := ioutil.ReadFile(route.FilePath) 199 | if err != nil { 200 | return route, err 201 | } 202 | if strings.HasPrefix(string(fileContent), "---") { 203 | if err := yaml.Unmarshal(fileContent, &route.rawMeta); err != nil { 204 | return route, err 205 | } 206 | 207 | route.Meta = make(map[string]interface{}, len(route.rawMeta)) 208 | for k, v := range route.rawMeta { 209 | route.Meta[k] = v 210 | } 211 | } 212 | } 213 | 214 | return route, nil 215 | } 216 | 217 | func EvalMetaExpressions(routes []Route) error { 218 | for i, r := range routes { 219 | for name, val := range r.rawMeta { 220 | if str, ok := val.(string); ok && strings.HasPrefix(strings.TrimSpace(str), "{{") { 221 | tmplStr := strings.Replace(str, "}}", " | toUnescapedJson }}", -1) 222 | tmpl := template.New(r.FilePath) 223 | tmpl = tmpl.Funcs(funcMap) 224 | tmpl, err := tmpl.Parse(tmplStr) 225 | if err != nil { 226 | return err 227 | } 228 | 229 | tmplCtx := map[string]interface{}{} 230 | for k, v := range r.rawMeta { 231 | tmplCtx[k] = v 232 | } 233 | tmplCtx["Route"] = r 234 | tmplCtx["Routes"] = routes 235 | 236 | var resultBytes bytes.Buffer 237 | if err := tmpl.Execute(&resultBytes, tmplCtx); err != nil { 238 | return err 239 | } 240 | 241 | var result interface{} 242 | if err := json.Unmarshal(resultBytes.Bytes(), &result); err != nil { 243 | return err 244 | } 245 | 246 | routes[i].Meta[name] = result 247 | } else { 248 | routes[i].Meta[name] = val 249 | } 250 | } 251 | } 252 | 253 | return nil 254 | } 255 | 256 | func ExpandRoutes(route *Route) error { 257 | for { 258 | 259 | oldRoutes := route.AllRoutes() 260 | oldHrefs := make([]string, len(oldRoutes)) 261 | for _, r := range oldRoutes { 262 | oldHrefs = append(oldHrefs, r.Href) 263 | } 264 | 265 | if err := ExpandDynamicRoutes(route); err != nil { 266 | return err 267 | } 268 | 269 | newRoutes := route.AllRoutes() 270 | newHrefs := make([]string, len(newRoutes)) 271 | for _, r := range newRoutes { 272 | newHrefs = append(newHrefs, r.Href) 273 | } 274 | 275 | if isEqualStringSet(oldHrefs, newHrefs) { 276 | return nil 277 | } 278 | } 279 | } 280 | 281 | func isEqualStringSet(a, b []string) bool { 282 | aMap := make(map[string]interface{}, len(a)) 283 | for _, aElem := range a { 284 | aMap[aElem] = nil 285 | } 286 | bMap := make(map[string]interface{}, len(b)) 287 | for _, bElem := range b { 288 | bMap[bElem] = nil 289 | if _, ok := aMap[bElem]; !ok { 290 | return false 291 | } 292 | } 293 | if len(aMap) != len(bMap) { 294 | return false 295 | } 296 | 297 | return true 298 | } 299 | 300 | func ExpandDynamicRoutes(route *Route) error { 301 | regex := *regexp.MustCompile(`\[([^\]]+)\]`) 302 | 303 | if err := EvalMetaExpressions(route.AllRoutes()); err != nil { 304 | return err 305 | } 306 | 307 | for _, r := range route.AllRoutes() { 308 | matches := regex.FindAllStringSubmatch(r.FilePath, -1) 309 | variables := make(map[string][]interface{}, len(matches)) 310 | for i := range matches { 311 | name := strings.TrimSpace(matches[i][1]) 312 | value := r.Meta[name] 313 | 314 | switch reflect.TypeOf(value).Kind() { 315 | case reflect.Slice, reflect.Array: 316 | variables[name] = value.([]interface{}) 317 | default: 318 | variables[name] = []interface{}{value} 319 | } 320 | } 321 | 322 | if len(variables) < 1 { 323 | continue 324 | } 325 | 326 | variableNames := make([]string, len(variables)) 327 | variableValues := make([][]interface{}, len(variables)) 328 | varIdx := 0 329 | for varName, varValue := range variables { 330 | variableNames[varIdx] = varName 331 | variableValues[varIdx] = varValue 332 | varIdx += 1 333 | } 334 | 335 | permutations := eachPermutation(variableValues...) 336 | 337 | newRoutes := make([]Route, len(permutations)) 338 | 339 | for permIdx, permutation := range permutations { 340 | href := r.rawHref 341 | newRoutes[permIdx] = r 342 | 343 | // TODO improve deep copying 344 | newRoutes[permIdx].Meta = make(map[string]interface{}, len(r.Meta)) 345 | for k, v := range r.Meta { 346 | newRoutes[permIdx].Meta[k] = v 347 | } 348 | 349 | for varIdx, varName := range variableNames { 350 | varValue := permutation[varIdx] 351 | regex := *regexp.MustCompile(`\[\s*` + varName + `\s*\]`) 352 | href = regex.ReplaceAllString(href, fmt.Sprint(varValue)) 353 | 354 | newRoutes[permIdx].Meta[varName] = varValue 355 | } 356 | newRoutes[permIdx].Href = href 357 | } 358 | 359 | replaceAllRoutesForFile(route, r.FilePath, newRoutes) 360 | } 361 | 362 | return nil 363 | } 364 | 365 | func replaceAllRoutesForFile(route *Route, filePath string, replaceWith []Route) { 366 | oldChildren := route.Children 367 | route.Children = make([]Route, 0, len(oldChildren)) 368 | found := false 369 | for _, child := range oldChildren { 370 | if child.FilePath != filePath { 371 | route.Children = append(route.Children, child) 372 | } else { 373 | if !found { 374 | route.Children = append(route.Children, replaceWith...) 375 | } 376 | found = true 377 | } 378 | } 379 | 380 | if found { 381 | return 382 | } 383 | 384 | for i := range route.Children { 385 | replaceAllRoutesForFile(&route.Children[i], filePath, replaceWith) 386 | } 387 | } 388 | 389 | func eachPermutation(values ...[]interface{}) [][]interface{} { 390 | // [[a, b], [1, 2, 3]] 391 | // -> 392 | // [[a, 1], [a, 2], [a, 3], [b, 1], [b, 2], [b, 3]] 393 | 394 | length := 0 395 | for _, vals := range values { 396 | if length == 0 { 397 | length = 1 398 | } 399 | length *= len(vals) 400 | } 401 | 402 | permutations := make([][]interface{}, length) 403 | 404 | for i := range permutations { 405 | permutations[i] = make([]interface{}, len(values)) 406 | acc := 1 407 | for j := range permutations[i] { 408 | permutations[i][j] = values[j][(acc*i*len(values[j])/length)%len(values[j])] 409 | acc *= len(values[j]) 410 | } 411 | 412 | } 413 | 414 | return permutations 415 | } 416 | 417 | func markdownToHTML(src []byte) ([]byte, error) { 418 | markdown := goldmark.New( 419 | goldmark.WithRendererOptions( 420 | html.WithUnsafe(), 421 | html.WithHardWraps()), 422 | goldmark.WithExtensions( 423 | extension.GFM, 424 | extension.Footnote, 425 | meta.Meta, 426 | highlighting.NewHighlighting( 427 | highlighting.WithStyle("dracula")))) 428 | 429 | var buf bytes.Buffer 430 | if err := markdown.Convert(src, &buf); err != nil { 431 | return []byte{}, err 432 | } 433 | return buf.Bytes(), nil 434 | } 435 | 436 | func filePathToHref(fpath string) string { 437 | switch ext := strings.ToLower(filepath.Ext(fpath)); ext { 438 | case ".html", ".htm", ".md", ".markdown": 439 | return strings.TrimSuffix(fpath, ext) 440 | } 441 | return fpath 442 | } 443 | 444 | func isIndexFile(fileInfo os.FileInfo) bool { 445 | return strings.HasPrefix(fileInfo.Name(), "index.") 446 | } 447 | 448 | func sortAsc(sortBy string, routes []Route) []Route { 449 | sorted := make([]Route, len(routes)) 450 | copy(sorted, routes) 451 | sort.Slice(sorted, func(i, j int) bool { return fmt.Sprint(routes[i].Meta[sortBy]) < fmt.Sprint(routes[j].Meta[sortBy]) }) 452 | return sorted 453 | } 454 | 455 | func sortDesc(sortBy string, routes []Route) []Route { 456 | sorted := make([]Route, len(routes)) 457 | copy(sorted, routes) 458 | sort.Slice(sorted, func(i, j int) bool { return fmt.Sprint(routes[i].Meta[sortBy]) > fmt.Sprint(routes[j].Meta[sortBy]) }) 459 | return sorted 460 | } 461 | 462 | func limit(limit int, routes []Route) []Route { 463 | if limit >= len(routes) { 464 | return routes 465 | } 466 | return routes[:limit] 467 | } 468 | 469 | func offset(offset int, routes []Route) []Route { 470 | if offset >= len(routes) { 471 | return []Route{} 472 | } 473 | return routes[offset:] 474 | } 475 | 476 | func filter(metaKey, metaPattern string, routes []Route) []Route { 477 | filtered := []Route{} 478 | for _, r := range routes { 479 | match, err := filepath.Match(metaPattern, fmt.Sprint(r.Meta[metaKey])) 480 | if err != nil { 481 | fmt.Println("Warning in Filter: Could not match", metaPattern, "with", r.Meta[metaKey]) 482 | continue 483 | } 484 | if match { 485 | filtered = append(filtered, r) 486 | } 487 | } 488 | return filtered 489 | } 490 | 491 | func filterHref(hrefPattern string, routes []Route) []Route { 492 | filtered := []Route{} 493 | for _, r := range routes { 494 | match, err := filepath.Match(hrefPattern, r.Href) 495 | if err != nil { 496 | fmt.Println("Warning in FilterHref: Could not match", hrefPattern, "with", r.Href) 497 | continue 498 | } 499 | if match { 500 | filtered = append(filtered, r) 501 | } 502 | } 503 | 504 | return filtered 505 | } 506 | 507 | func filterFilePath(filePathPattern string, routes []Route) []Route { 508 | filtered := []Route{} 509 | for _, r := range routes { 510 | match, err := filepath.Match(filePathPattern, r.FilePath) 511 | if err != nil { 512 | fmt.Println("Warning in FilterFilePath: Could not match", filePathPattern, "with", r.FilePath) 513 | continue 514 | } 515 | if match { 516 | filtered = append(filtered, r) 517 | } 518 | } 519 | 520 | return filtered 521 | } 522 | 523 | func filterFileName(fileNamePattern string, routes []Route) []Route { 524 | filtered := []Route{} 525 | for _, r := range routes { 526 | fname := filepath.Base(r.FilePath) 527 | match, err := filepath.Match(fileNamePattern, fname) 528 | if err != nil { 529 | fmt.Println("Warning in FilterFileName: Could not match", fileNamePattern, "with", fname) 530 | continue 531 | } 532 | if match { 533 | filtered = append(filtered, r) 534 | } 535 | } 536 | 537 | return filtered 538 | } 539 | 540 | func fn(source string, args ...interface{}) interface{} { 541 | vm := goja.New() 542 | v, err := vm.RunString(source) 543 | if err != nil { 544 | panic(err) 545 | } 546 | 547 | f, ok := goja.AssertFunction(v) 548 | if !ok { 549 | panic("Not a function") 550 | } 551 | 552 | jsArgs := make([]goja.Value, len(args)) 553 | 554 | for i := 0; i < len(args); i++ { 555 | jsArgs[i] = vm.ToValue(args[i]) 556 | } 557 | 558 | res, err := f(goja.Undefined(), jsArgs...) 559 | 560 | if err != nil { 561 | panic(err) 562 | } 563 | 564 | return res.Export() 565 | } 566 | 567 | func toUnescapedJson(val interface{}) template.HTML { 568 | result, _ := json.Marshal(val) 569 | return template.HTML(string(result)) 570 | } 571 | --------------------------------------------------------------------------------