├── generator ├── test │ ├── i18n │ │ ├── en-UK.ini │ │ ├── es-ES.ini │ │ └── en-US.ini │ ├── sources │ │ ├── en-UK │ │ │ └── static │ │ │ │ └── 404 │ │ ├── es-ES │ │ │ └── Dockerfile │ │ ├── global │ │ │ ├── Dockerfile │ │ │ ├── config.json │ │ │ └── tmpl │ │ │ │ ├── sample.mustache │ │ │ │ └── sample2.mustache │ │ └── en-US │ │ │ └── tmpl │ │ │ └── sample.mustache │ └── config │ │ ├── en-UK │ │ └── config.ini │ │ ├── es-ES │ │ └── config.ini │ │ └── global │ │ └── config.ini ├── generator_test.go ├── scan.go ├── scan_test.go ├── generator.go ├── collector_test.go ├── render_test.go ├── render.go └── collector.go ├── examples ├── blog │ ├── static │ │ ├── 404 │ │ ├── 500 │ │ ├── robots.txt │ │ ├── hello.txt │ │ └── sitemap.xml │ ├── post.mustache │ ├── README.md │ ├── config.json │ ├── home.mustache │ └── main_layout.mustache └── debugger │ ├── public │ ├── logo.png │ └── img │ │ └── logo.png │ ├── home.mustache │ └── config.json ├── .gitignore ├── skeleton ├── files │ └── blog │ │ ├── sources │ │ ├── global │ │ │ ├── static │ │ │ │ ├── 404 │ │ │ │ ├── 500 │ │ │ │ ├── robots.txt │ │ │ │ ├── hello.txt │ │ │ │ └── sitemap.xml │ │ │ ├── Dockerfile │ │ │ ├── tmpl │ │ │ │ ├── post.mustache │ │ │ │ ├── home.mustache │ │ │ │ └── main_layout.mustache │ │ │ └── config.json │ │ └── es_ES │ │ │ ├── static │ │ │ ├── 404 │ │ │ └── 500 │ │ │ └── tmpl │ │ │ └── home.mustache │ │ ├── config │ │ ├── global │ │ │ ├── routes.ini │ │ │ └── config.ini │ │ └── es_ES │ │ │ ├── routes.ini │ │ │ └── config.ini │ │ └── i18n │ │ ├── en_US.ini │ │ └── es_ES.ini ├── skeleton_test.go └── skeleton.go ├── .travis.yml ├── Dockerfile ├── main.go ├── cmd ├── root.go ├── skeleton.go ├── skeleton_test.go ├── serve.go ├── serve_test.go ├── generate.go └── generate_test.go ├── engine ├── decode_test.go ├── decode.go ├── config.go ├── page.go ├── backend_test.go ├── backend.go ├── template_store.go ├── mustache.go ├── mustache_test.go ├── response.go ├── config_test.go ├── templates.go ├── factory.go ├── engine.go ├── engine_test.go ├── factory_test.go ├── handler.go ├── response_test.go └── handler_test.go ├── Gopkg.toml ├── coverage.sh ├── Makefile ├── Gopkg.lock ├── README.md ├── LICENSE └── statik └── statik.go /generator/test/i18n/en-UK.ini: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /generator/test/i18n/es-ES.ini: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /generator/test/sources/en-UK/static/404: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /generator/test/sources/es-ES/Dockerfile: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /generator/test/sources/global/Dockerfile: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /generator/test/sources/global/config.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /generator/test/sources/en-US/tmpl/sample.mustache: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /generator/test/sources/global/tmpl/sample.mustache: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /generator/test/sources/global/tmpl/sample2.mustache: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/blog/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /examples/blog/static/hello.txt: -------------------------------------------------------------------------------- 1 | Hello, I am a text/plain content. -------------------------------------------------------------------------------- /generator/test/config/en-UK/config.ini: -------------------------------------------------------------------------------- 1 | [site] 2 | iso_lang = en-UK -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | api2html 2 | generator/test/output 3 | coverage.out 4 | vendor 5 | -------------------------------------------------------------------------------- /skeleton/files/blog/sources/global/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /generator/test/config/es-ES/config.ini: -------------------------------------------------------------------------------- 1 | [site] 2 | iso_lang = es-ES 3 | lang = es -------------------------------------------------------------------------------- /skeleton/files/blog/sources/global/static/hello.txt: -------------------------------------------------------------------------------- 1 | Hello, I am a text/plain content. -------------------------------------------------------------------------------- /generator/test/i18n/en-US.ini: -------------------------------------------------------------------------------- 1 | [base] 2 | tab_home = Home 3 | 4 | [system] 5 | not_found = Page not found! -------------------------------------------------------------------------------- /examples/debugger/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devopsfaith/api2html/HEAD/examples/debugger/public/logo.png -------------------------------------------------------------------------------- /examples/debugger/public/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devopsfaith/api2html/HEAD/examples/debugger/public/img/logo.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.9 5 | 6 | before_install: 7 | - make prepare 8 | 9 | script: 10 | - make coveralls 11 | -------------------------------------------------------------------------------- /skeleton/files/blog/config/global/routes.ini: -------------------------------------------------------------------------------- 1 | [route-home] 2 | metadata_title = Home 3 | is_home = true 4 | 5 | [route-posts] 6 | metadata_title = Post 7 | -------------------------------------------------------------------------------- /skeleton/files/blog/config/es_ES/routes.ini: -------------------------------------------------------------------------------- 1 | [route-home] 2 | metadata_title = Inicio 3 | is_home = true 4 | 5 | [route-posts] 6 | metadata_title = Entradas 7 | -------------------------------------------------------------------------------- /skeleton/files/blog/config/es_ES/config.ini: -------------------------------------------------------------------------------- 1 | [site] 2 | iso_lang = en-US 3 | lang = en 4 | 5 | [templates] 6 | inicio = home.mustache 7 | entradas = post.mustache 8 | -------------------------------------------------------------------------------- /skeleton/files/blog/i18n/en_US.ini: -------------------------------------------------------------------------------- 1 | [base] 2 | example = Hi! 3 | 4 | [url] 5 | posts = posts 6 | home = home 7 | 8 | [system] 9 | not_found = Page not found! 10 | -------------------------------------------------------------------------------- /skeleton/files/blog/i18n/es_ES.ini: -------------------------------------------------------------------------------- 1 | [base] 2 | example = Hola! 3 | 4 | [url] 5 | posts = entradas 6 | home = inicio 7 | 8 | [system] 9 | not_found = Pagina no encontrada! 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.7 2 | 3 | EXPOSE 8080 4 | 5 | ADD ./api2html /etc/api2html/api2html 6 | 7 | WORKDIR /etc/api2html/ 8 | 9 | ENTRYPOINT [ "/etc/api2html/./api2html" ] 10 | 11 | CMD [ "-h" ] 12 | -------------------------------------------------------------------------------- /generator/test/config/global/config.ini: -------------------------------------------------------------------------------- 1 | [site] 2 | name = my-site 3 | base_url = / 4 | iso_lang = en-US 5 | lang = en 6 | url_static = http://static.example.com 7 | copyright = 2017 - 2018 example.com 8 | krakend_url = http://krakend:8080 -------------------------------------------------------------------------------- /skeleton/files/blog/sources/global/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM devopsfaith/api2html 2 | 3 | ADD tmpl/* /etc/api2hmtl/ 4 | ADD static/* /etc/api2html/static/ 5 | ADD config.json /etc/api2html/config.json 6 | 7 | CMD [ "-d", "-c", "/etc/api2hmtl/config.json" ] 8 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | //go:generate statik -src=skeleton/files 4 | 5 | import ( 6 | "log" 7 | 8 | "github.com/devopsfaith/api2html/cmd" 9 | ) 10 | 11 | func main() { 12 | if err := cmd.Execute(); err != nil { 13 | log.Println("error:", err.Error()) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var rootCmd = &cobra.Command{ 8 | Use: "api2html", 9 | Short: "Generate HTML on the fly from your API.", 10 | } 11 | 12 | // Execute executes the root command 13 | func Execute() error { 14 | return rootCmd.Execute() 15 | } 16 | -------------------------------------------------------------------------------- /generator/generator_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func ExampleGenerator() { 9 | // log.SetOutput(ioutil.Discard) 10 | if err := New(os.Getenv("PWD")+"/test", "ignore").Generate("*"); err != nil { 11 | fmt.Println("generation aborted:", err) 12 | } 13 | fmt.Println("ok") 14 | // Output: 15 | // ok 16 | } 17 | -------------------------------------------------------------------------------- /examples/blog/static/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | http://localhost:8080/ 5 | monthly 6 | 0.8 7 | 8 | 9 | http://localhost:8080/post/1 10 | monthly 11 | 0.2 12 | 13 | -------------------------------------------------------------------------------- /skeleton/files/blog/sources/global/static/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{Config.site.base_url}}/ 5 | monthly 6 | 0.8 7 | 8 | 9 | {{Config.site.base_url}}/{{ I18N.url.posts }}/1 10 | monthly 11 | 0.2 12 | 13 | 14 | -------------------------------------------------------------------------------- /skeleton/files/blog/config/global/config.ini: -------------------------------------------------------------------------------- 1 | [site] 2 | name = API2HTML Demo 3 | base_url = / 4 | iso_lang = en-US 5 | lang = en 6 | url_static = http://localhost:8080 7 | copyright = © 2018 My Company 8 | 9 | [templates] 10 | home = home.mustache 11 | post = post.mustache 12 | 13 | [layouts] 14 | main = main_layout.mustache 15 | 16 | [lang_en-US] 17 | name = en-US 18 | dir = ltr 19 | domain = en 20 | label = English (US) 21 | url = http://localhost:8080 22 | 23 | [lang_es-ES] 24 | name = es-ES 25 | dir = ltr 26 | domain = es 27 | label = Español 28 | url = http://localhost:8080 29 | -------------------------------------------------------------------------------- /examples/blog/static/404: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Page not found 7 | 8 | 9 |

Page not found!

10 |

The page you are looking for is not hosted in this site

11 |

You might want to customize this file by editing static/404

12 | 13 | -------------------------------------------------------------------------------- /examples/blog/static/500: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Bummer! 7 | 8 | 9 |

API response went wild!

10 |

The response from the API was weird an unable to process it.

11 |

You might want to customize this file by editing static/500

12 | -------------------------------------------------------------------------------- /examples/debugger/home.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ Extra.metadata_title }} 6 | 7 | 8 | 9 |

Debug example

10 |

This is a simple page that uses a single page and loads data from an external API so you can see how the debug template changes.

11 | 12 | 17 | 18 | {{ #Data }} 19 |

{{ title }}

20 |

{{ body }}

21 | 22 | {{ /Data }} 23 | 24 | 25 | 26 | {{ >api2html/debug }} 27 | -------------------------------------------------------------------------------- /skeleton/files/blog/sources/global/static/500: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Boom! 8 | 9 | 10 |

API response went wild!

11 |

The response from the API was weird an unable to process it.

12 |

You might want to customize this file by editing static/500

13 | 14 | -------------------------------------------------------------------------------- /skeleton/files/blog/sources/es_ES/static/404: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <% I18N.system.not_found %> 8 | 9 | 10 |

Página no encontrada!

11 |

La página que buscas no está alojada en esta web!

12 |

Puede que quieras personalizar este fichero editando static/404

13 | 14 | -------------------------------------------------------------------------------- /skeleton/files/blog/sources/global/static/404: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <% I18N.system.not_found %> 8 | 9 | 10 |

Page not found!

11 |

The page you are looking for is not hosted in this site

12 |

You might want to customize this file by editing static/404

13 | 14 | -------------------------------------------------------------------------------- /skeleton/files/blog/sources/es_ES/static/500: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Boom! 8 | 9 | 10 |

La respuesta de la API se volvió loca!

11 |

Hubo un problema con la respuesta de la API y fuí incapaz de procesarla.

12 |

Puede que quieras personalizar este fichero editando static/500

13 | 14 | -------------------------------------------------------------------------------- /examples/blog/post.mustache: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{ Data.title }}

4 | 5 | 6 |

{{ Data.body }}

7 |
8 |

Back

9 |
10 |
11 |

Where the data comes from?

12 |

The response from the API is: 13 | 14 | https://jsonplaceholder.typicode.com/posts/{{ Data.id }} 15 | 16 |

17 |
18 | {
19 |   "userId": {{ Data.userId }},
20 |   "id": {{ Data.id }},
21 |   "title": "{{ Data.title }}",
22 |   "body": "{{ Data.body }}"
23 | }
24 | 
25 | 26 |
27 |
28 | 29 | -------------------------------------------------------------------------------- /skeleton/files/blog/sources/global/tmpl/post.mustache: -------------------------------------------------------------------------------- 1 | {{=<% %>=}}
2 |
3 |

{{ Data.title }}

4 | 5 | 6 |

{{ Data.body }}

7 |
8 |

Back

9 |
10 |
11 |

Where the data comes from?

12 |

The response from the API is: 13 | 14 | https://jsonplaceholder.typicode.com/posts/{{ Data.id }} 15 | 16 |

17 |
18 | {
19 |   "userId": {{ Data.userId }},
20 |   "id": {{ Data.id }},
21 |   "title": "{{ Data.title }}",
22 |   "body": "{{ Data.body }}"
23 | }
24 | 
25 | 26 |
27 |
28 | -------------------------------------------------------------------------------- /examples/blog/README.md: -------------------------------------------------------------------------------- 1 | # Example blog 2 | 3 | The following code shows a very basic example of API2HTML serving pages. In order to run it: 4 | 5 | cd examples/blog 6 | api2html serve -d -c config.json 7 | 8 | This will start the server and you will be able to navigate the following pages: 9 | 10 | - [Home](http://localhost:8080/) 11 | - [Post](http://localhost:8080/posts/1) 12 | - [robots.txt](http://localhost:8080/robots.txt) 13 | - [sitemap.xml](http://localhost:8080/sitemap.xml) 14 | - [hello.txt](http://localhost:8080/hello.txt): An example of `text/plain` content 15 | - [404 page](http://localhost:8080/idontexist) 16 | - 500 page: You need to break the API response to see it 17 | 18 | See the `config.json` to understand how this works and the `tmpl` folder to see how `{{data}}` is injected. 19 | 20 | The template system is [Mustache](https://mustache.github.io) 21 | -------------------------------------------------------------------------------- /engine/decode_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestJSONDecoder(t *testing.T) { 9 | r := ResponseContext{} 10 | if err := JSONDecoder(bytes.NewBufferString(`{"a":"b"}`), &r); err != nil { 11 | t.Error(err) 12 | return 13 | } 14 | if len(r.Array) != 0 { 15 | t.Errorf("unexpected array value: %v", r.Array) 16 | } 17 | if v, ok := r.Data["a"]; !ok || "b" != v.(string) { 18 | t.Errorf("unexpected obj value: %v", r.Data) 19 | } 20 | } 21 | 22 | func TestJSONArrayDecoder(t *testing.T) { 23 | r := ResponseContext{} 24 | if err := JSONArrayDecoder(bytes.NewBufferString(`[{"a":"b"}]`), &r); err != nil { 25 | t.Error(err) 26 | return 27 | } 28 | if len(r.Array) != 1 { 29 | t.Errorf("unexpected array value: %v", r.Array) 30 | } 31 | if len(r.Data) != 0 { 32 | t.Errorf("unexpected obj value: %v", r.Data) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/blog/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "robots": true, 3 | "sitemap": true, 4 | "static_txt_content": [ 5 | "hello.txt" 6 | ], 7 | "pages":[ 8 | { 9 | "name": "post", 10 | "URLPattern": "/posts/:post", 11 | "BackendURLPattern": "https://jsonplaceholder.typicode.com/posts/:post", 12 | "Template": "post", 13 | "Layout": "main", 14 | "CacheTTL": "3600s" 15 | }, 16 | { 17 | "name": "home", 18 | "URLPattern": "/", 19 | "BackendURLPattern": "https://jsonplaceholder.typicode.com/posts", 20 | "Template": "home", 21 | "Layout": "main", 22 | "CacheTTL": "3600s", 23 | "IsArray": true, 24 | "extra": {"is_home":true } 25 | } 26 | ], 27 | "templates": {"home":"home.mustache","post":"post.mustache"}, 28 | "layouts": {"main":"main_layout.mustache"}, 29 | "extra":{ 30 | "lang": "en-US", 31 | "copyright": "© 2018 My Company" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | [[constraint]] 2 | branch = "master" 3 | name = "github.com/Unknwon/goconfig" 4 | 5 | [[constraint]] 6 | name = "github.com/cbroglie/mustache" 7 | version = "1.0.0" 8 | 9 | [[constraint]] 10 | name = "github.com/fsnotify/fsnotify" 11 | version = "1.4.7" 12 | 13 | [[constraint]] 14 | branch = "master" 15 | name = "github.com/gin-contrib/static" 16 | 17 | [[constraint]] 18 | name = "github.com/gin-gonic/gin" 19 | version = "1.2.0" 20 | 21 | [[constraint]] 22 | branch = "master" 23 | name = "github.com/gregjones/httpcache" 24 | 25 | [[constraint]] 26 | name = "github.com/spf13/cobra" 27 | version = "0.0.1" 28 | 29 | [prune] 30 | non-go = true 31 | go-tests = true 32 | unused-packages = true 33 | 34 | [[constraint]] 35 | name = "github.com/newrelic/go-agent" 36 | version = "1.11.0" 37 | 38 | [[constraint]] 39 | name = "github.com/rakyll/statik" 40 | version = "0.1.1" 41 | 42 | [[constraint]] 43 | name = "github.com/ghodss/yaml" 44 | version = "1.0.0" 45 | -------------------------------------------------------------------------------- /examples/debugger/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "pages":[ 3 | { 4 | "name": "home", 5 | "URLPattern": "/", 6 | "Template": "home", 7 | "CacheTTL": "1s" 8 | }, 9 | { 10 | "name": "post", 11 | "URLPattern": "/post/:post", 12 | "BackendURLPattern": "https://jsonplaceholder.typicode.com/posts/:post", 13 | "Template": "post", 14 | "CacheTTL": "1s", 15 | "extra": { 16 | "metadata_title":"API2HTML post page debugger" 17 | } 18 | }, 19 | { 20 | "name": "post", 21 | "URLPattern": "/posts", 22 | "BackendURLPattern": "https://jsonplaceholder.typicode.com/posts", 23 | "Template": "post", 24 | "IsArray": true, 25 | "CacheTTL": "1s", 26 | "extra": { 27 | "metadata_title":"API2HTML post page debugger" 28 | } 29 | } 30 | ], 31 | "templates": {"home":"home.mustache","post":"home.mustache"}, 32 | "extra":{ 33 | "lang": "en-US", 34 | "metadata_title": "API2HTML debugger" 35 | }, 36 | "public_folder": { 37 | "path_to_folder": "public", 38 | "url_prefix": "/" 39 | } 40 | } -------------------------------------------------------------------------------- /engine/decode.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | ) 7 | 8 | // Decoder defines the signature for response decoder functions 9 | type Decoder func(io.Reader, *ResponseContext) error 10 | 11 | // JSONDecoder decodes the reader content and puts it into the Data property of the 12 | // injected ResponseContext 13 | func JSONDecoder(r io.Reader, c *ResponseContext) error { 14 | var target map[string]interface{} 15 | decoder := json.NewDecoder(r) 16 | decoder.UseNumber() 17 | if err := decoder.Decode(&target); err != nil { 18 | return err 19 | } 20 | c.Data = target 21 | return nil 22 | } 23 | 24 | // JSONArrayDecoder decodes the reader content and puts it into the Array property of the 25 | // injected ResponseContext 26 | func JSONArrayDecoder(r io.Reader, c *ResponseContext) error { 27 | var target []map[string]interface{} 28 | decoder := json.NewDecoder(r) 29 | decoder.UseNumber() 30 | if err := decoder.Decode(&target); err != nil { 31 | return err 32 | } 33 | c.Array = target 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /engine/config.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "encoding/json" 5 | "bytes" 6 | "io" 7 | "os" 8 | 9 | "github.com/ghodss/yaml" 10 | 11 | ) 12 | 13 | 14 | // ParseConfigFromFile creates a Config with the contents of the received filepath 15 | func ParseConfigFromFile(path string) (Config, error) { 16 | configFile, err := os.Open(path) 17 | if err != nil { 18 | return Config{}, err 19 | } 20 | cfg, err := ParseConfig(configFile) 21 | configFile.Close() 22 | return cfg, err 23 | } 24 | 25 | // ParseConfig parses the content of the reader into a Config 26 | func ParseConfig(r io.Reader) (Config, error) { 27 | var cfg Config 28 | var buf bytes.Buffer 29 | 30 | buf.ReadFrom(r) 31 | cb := buf.Bytes() 32 | 33 | switch { 34 | case bytes.HasPrefix(bytes.TrimSpace(cb), []byte("{")): 35 | err := json.Unmarshal(cb, &cfg) 36 | if err != nil { 37 | return cfg, err 38 | } 39 | default: 40 | err := yaml.Unmarshal(cb, &cfg) 41 | if err != nil { 42 | return cfg, err 43 | } 44 | } 45 | 46 | for p, page := range cfg.Pages { 47 | if len(page.Extra) == 0 { 48 | cfg.Pages[p].Extra = cfg.Extra 49 | continue 50 | } 51 | for k, v := range cfg.Extra { 52 | if _, ok := page.Extra[k]; !ok { 53 | cfg.Pages[p].Extra[k] = v 54 | } 55 | } 56 | } 57 | return cfg, nil 58 | } 59 | -------------------------------------------------------------------------------- /generator/scan.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | ) 9 | 10 | type Scanner interface { 11 | Scan() []TmplFolder 12 | } 13 | 14 | type TmplFolder struct { 15 | Path string 16 | Content []string 17 | } 18 | 19 | func NewScanner(ts []string) Scanner { 20 | return TmplScanner(ts) 21 | } 22 | 23 | type TmplScanner []string 24 | 25 | func (ts TmplScanner) Scan() []TmplFolder { 26 | res := []TmplFolder{} 27 | for _, prefix := range ts { 28 | if tmpls := getTemplatesInFolder(prefix); len(tmpls) > 0 { 29 | res = append(res, TmplFolder{ 30 | Content: tmpls, 31 | Path: prefix, 32 | }) 33 | } 34 | } 35 | return res 36 | } 37 | 38 | func getTemplatesInFolder(prefix string) []string { 39 | templates := []string{} 40 | for _, fileName := range []string{"config.json", "Dockerfile"} { 41 | if _, err := os.Stat(fmt.Sprintf("%s/%s", prefix, fileName)); os.IsNotExist(err) { 42 | log.Println(err.Error()) 43 | continue 44 | } 45 | templates = append(templates, fileName) 46 | } 47 | for _, folder := range []string{"tmpl", "static"} { 48 | files, err := ioutil.ReadDir(fmt.Sprintf("%s/%s", prefix, folder)) 49 | if err != nil { 50 | log.Println(err.Error()) 51 | continue 52 | } 53 | 54 | for _, f := range files { 55 | templates = append(templates, fmt.Sprintf("%s/%s", folder, f.Name())) 56 | } 57 | } 58 | return templates 59 | } 60 | -------------------------------------------------------------------------------- /cmd/skeleton.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/devopsfaith/api2html/skeleton" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var ( 9 | outputPath string 10 | 11 | skelCmd = &cobra.Command{ 12 | Use: "skel", 13 | Short: "Executes commands to manage/create skeletons", 14 | Aliases: []string{"skeleton"}, 15 | Example: "api2html skel", 16 | } 17 | 18 | createCmd = &cobra.Command{ 19 | Use: "create", 20 | Short: "Creates a skeleton structure.", 21 | Example: "api2html skel create -o outputPath ", 22 | } 23 | 24 | blogCmd = &cobra.Command{ 25 | Use: "blog", 26 | Short: "Creates the blog skeleton example.", 27 | RunE: skelWrapper{defaultBlogSkelFactory}.Create, 28 | Example: "api2html skel create -o outputPath blog", 29 | } 30 | ) 31 | 32 | func init() { 33 | rootCmd.AddCommand(skelCmd) 34 | skelCmd.AddCommand(createCmd) 35 | createCmd.AddCommand(blogCmd) 36 | 37 | createCmd.PersistentFlags().StringVarP(&outputPath, "outputPath", "o", "example", "Output path for the example generation skel") 38 | 39 | } 40 | 41 | type skelFactory func(outputPath string) skeleton.Skel 42 | 43 | func defaultBlogSkelFactory(outputPath string) skeleton.Skel { 44 | return skeleton.NewBlog(outputPath) 45 | } 46 | 47 | type skelWrapper struct { 48 | sk skelFactory 49 | } 50 | 51 | func (sw skelWrapper) Create(_ *cobra.Command, _ []string) error { 52 | return sw.sk(outputPath).Create() 53 | } 54 | -------------------------------------------------------------------------------- /coverage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Generate test coverage statistics for Go packages. 3 | # 4 | # Works around the fact that `go test -coverprofile` currently does not work 5 | # with multiple packages, see https://code.google.com/p/go/issues/detail?id=6909 6 | # 7 | # Usage: script/coverage [--html|--coveralls] 8 | # 9 | # --html Additionally create HTML report and open it in browser 10 | # --coveralls Push coverage statistics to coveralls.io 11 | # 12 | # File taken from https://github.com/mlafeldt/chef-runner/blob/v0.7.0/script/coverage 13 | 14 | set -e 15 | 16 | workdir=.cover 17 | profile="$workdir/cover.out" 18 | mode=count 19 | 20 | generate_cover_data() { 21 | rm -rf "$workdir" 22 | mkdir "$workdir" 23 | 24 | for pkg in "$@"; do 25 | f="$workdir/$(echo $pkg | tr / -).cover" 26 | go test -covermode="$mode" -coverprofile="$f" "$pkg" 27 | done 28 | 29 | echo "mode: $mode" >"$profile" 30 | grep -h -v "^mode:" "$workdir"/*.cover >>"$profile" 31 | } 32 | 33 | show_cover_report() { 34 | go tool cover -${1}="$profile" 35 | } 36 | 37 | push_to_coveralls() { 38 | echo "Pushing coverage statistics to coveralls.io" 39 | goveralls -coverprofile="$profile" 40 | } 41 | 42 | generate_cover_data $(go list ./...) 43 | show_cover_report func 44 | case "$1" in 45 | "") 46 | ;; 47 | --html) 48 | show_cover_report html ;; 49 | --coveralls) 50 | push_to_coveralls ;; 51 | *) 52 | echo >&2 "error: invalid option: $1"; exit 1 ;; 53 | esac 54 | -------------------------------------------------------------------------------- /cmd/skeleton_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/devopsfaith/api2html/skeleton" 9 | ) 10 | 11 | func Test_defaultSkelFactory(t *testing.T) { 12 | g := defaultBlogSkelFactory("test") 13 | switch g.(type) { 14 | case skeleton.Skel: 15 | default: 16 | t.Errorf("unexpected generator type: %T", g) 17 | } 18 | } 19 | 20 | func Test_skelWrapper_koErroredSkel(t *testing.T) { 21 | expectedError := fmt.Errorf("expect me") 22 | 23 | skel := skelWrapper{func(_ string) skeleton.Skel { 24 | return erroredSkel{expectedError} 25 | }} 26 | 27 | if err := skel.Create(nil, []string{}); err == nil { 28 | t.Error("expecting error!") 29 | } else if err != expectedError { 30 | t.Errorf("unexpected error! want: %s, got: %s", expectedError.Error(), err.Error()) 31 | } 32 | } 33 | 34 | func Test_skelWrapper(t *testing.T) { 35 | 36 | skel := skelWrapper{func(_ string) skeleton.Skel { 37 | return simpleSkel{outputPath} 38 | }} 39 | 40 | defer os.Remove("example") 41 | if err := skel.Create(nil, []string{}); err != nil { 42 | t.Errorf("unexpected error: %s", err.Error()) 43 | } 44 | if _, err := os.Stat("example"); err != nil { 45 | t.Errorf("cannot locate test output dir: %s", err.Error()) 46 | } 47 | } 48 | 49 | type erroredSkel struct { 50 | err error 51 | } 52 | 53 | func (e erroredSkel) Create() error { 54 | return e.err 55 | } 56 | 57 | type simpleSkel struct { 58 | outputPath string 59 | } 60 | 61 | func (s simpleSkel) Create() error { 62 | return os.Mkdir(s.outputPath, os.ModePerm) 63 | } 64 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all prepare deps test build server_build docker 2 | 3 | GOLANG_VERSION=1.9.3-alpine3.7 4 | DEP_VERSION=0.4.1 5 | OS=$(shell uname | tr '[:upper:]' '[:lower:]') 6 | PACKAGES=$(shell go list ./...) 7 | GOBASEDIR=src/github.com/devopsfaith/api2html 8 | 9 | all: deps test build 10 | 11 | docker_all: docker_deps docker_build 12 | 13 | prepare: 14 | @echo "Installing statik..." 15 | @go get github.com/rakyll/statik 16 | @echo "Installing dep..." 17 | @curl -Ls "https://github.com/golang/dep/releases/download/v${DEP_VERSION}/dep-${OS}-amd64" -o "${GOPATH}/bin/dep" 18 | @chmod a+x ${GOPATH}/bin/dep 19 | 20 | deps: 21 | @echo "Setting up the vendors folder..." 22 | @dep ensure -v 23 | @echo "" 24 | @echo "Resolved dependencies:" 25 | @dep status 26 | @echo "" 27 | 28 | test: 29 | go test -cover -v $(PACKAGES) 30 | 31 | build: 32 | @echo "Generating skeleton code..." 33 | @go generate 34 | @echo "Building the binary..." 35 | @go build -a -o api2html 36 | @echo "You can now use ./api2html" 37 | 38 | docker: docker_build 39 | docker build -t devopsfaith/api2html . 40 | rm api2html 41 | 42 | docker_deps: 43 | docker run --rm -it -e "GOPATH=/go" -v "${PWD}:/go/${GOBASEDIR}" -w /go/${GOBASEDIR} lushdigital/docker-golang-dep ensure -v 44 | 45 | docker_build: 46 | @echo "You must run make deps or make docker_deps" 47 | docker run --rm -it -e "GOPATH=/go" -v "${PWD}:/go/${GOBASEDIR}" -w /go/${GOBASEDIR} golang:${GOLANG_VERSION} go build -o api2html 48 | 49 | coveralls: all 50 | go get github.com/mattn/goveralls 51 | sh coverage.sh --coveralls 52 | -------------------------------------------------------------------------------- /generator/scan_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestTmplScanner(t *testing.T) { 10 | 11 | tt := []struct { 12 | iso string 13 | nodes int 14 | }{ 15 | {"en-US", 2}, 16 | {"en-UK", 2}, 17 | {"es-ES", 2}, 18 | {"unknown", 1}, 19 | } 20 | 21 | pwd := os.Getenv("PWD") 22 | sourceFolder := fmt.Sprintf("%s/test/sources", pwd) 23 | 24 | for _, tc := range tt { 25 | t.Run(tc.iso, func(t *testing.T) { 26 | paths := []string{ 27 | fmt.Sprintf("%s/global", sourceFolder), 28 | fmt.Sprintf("%s/%s", sourceFolder, tc.iso), 29 | } 30 | scanner := NewScanner(paths) 31 | 32 | tmpls := scanner.Scan() 33 | if len(tmpls) != tc.nodes { 34 | t.Errorf("[%s] unexpected scan result. have %d nodes, want %d", tc.iso, len(tmpls), tc.nodes) 35 | return 36 | } 37 | 38 | if tmpls[0].Path != paths[0] { 39 | t.Errorf("[%s - 0] unexpected path. have %s, want %s", tc.iso, tmpls[0].Path, paths[0]) 40 | return 41 | } 42 | if len(tmpls[0].Content) != 4 { 43 | t.Errorf("[%s - 0] unexpected content size. have %d, want 4", tc.iso, len(tmpls[0].Content)) 44 | return 45 | } 46 | if tc.nodes > 1 { 47 | if tmpls[1].Path != paths[1] { 48 | t.Errorf("[%s - 1] unexpected path. have %s, want %s", tc.iso, tmpls[1].Path, paths[1]) 49 | return 50 | } 51 | if len(tmpls[1].Content) != 1 { 52 | t.Errorf("[%s - 1] unexpected content size. have %d, want 1", tc.iso, len(tmpls[1].Content)) 53 | return 54 | } 55 | } 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /skeleton/files/blog/sources/global/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "robots": true, 3 | "sitemap": true, 4 | "static_txt_content": [ 5 | {{# Config.static_txt_files.hello }}"{{ Config.static_txt_files.hello }}"{{/ Config.static_txt_files.hello }} 6 | ], 7 | "pages":[ 8 | { 9 | "name": "{{ I18N.url.posts }}", 10 | "URLPattern": "/{{ I18N.url.posts }}/:post", 11 | "BackendURLPattern": "https://jsonplaceholder.typicode.com/posts/:post", 12 | "Template": "post", 13 | "Layout": "main", 14 | "CacheTTL": "3600s"{{# Config.route-posts }}, 15 | "extra": {{ . }}{{/ Config.route-posts }} 16 | }, 17 | { 18 | "name": "{{ I18N.url.home }}", 19 | "URLPattern": "/", 20 | "BackendURLPattern": "https://jsonplaceholder.typicode.com/posts", 21 | "Template": "home", 22 | "Layout": "main", 23 | "IsArray": true, 24 | "CacheTTL": "3600s"{{# Config.route-home }}, 25 | "extra": {{ . }}{{/ Config.route-home }} 26 | } 27 | ], 28 | {{# Config.templates }}"templates": {{ . }},{{/ Config.templates }} 29 | {{# Config.layouts }}"layouts": {{ . }},{{/ Config.layouts }} 30 | "extra":{ 31 | {{# Config.site.iso_lang }}"lang": "{{ Config.site.iso_lang }}",{{/ Config.site.iso_lang }} 32 | {{# Config.langs }}"languages": {{ . }},{{/ Config.langs }} 33 | {{# Config.site.url_static }}"url_static": "{{ Config.site.url_static }}",{{/ Config.site.url_static}} 34 | {{# Config.site.copyright }}"copyright": "{{ Config.site.copyright }}",{{/ Config.site.copyright}} 35 | "url_static_revision": 1 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /engine/page.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | // NewMustachePageFactory creates a MustachePageFactory with the injected params 11 | func NewMustachePageFactory(e *gin.Engine, ts *TemplateStore) MustachePageFactory { 12 | return MustachePageFactory{e, ts} 13 | } 14 | 15 | // MustachePageFactory is a component that sets up the gin engine and the template store 16 | type MustachePageFactory struct { 17 | Engine *gin.Engine 18 | TemplateStore *TemplateStore 19 | } 20 | 21 | // Build sets up the injected gin engine and template store depending on the contents of 22 | // the received configuration 23 | func (m *MustachePageFactory) Build(cfg Config) { 24 | templates, err := NewMustacheRendererMap(cfg) 25 | if err != nil { 26 | panic(err) 27 | } 28 | 29 | for _, page := range cfg.Pages { 30 | h := NewHandler(NewHandlerConfig(page), m.TemplateStore.Subscribe) 31 | m.Engine.GET(page.URLPattern, h.HandlerFunc) 32 | 33 | time.Sleep(100 * time.Millisecond) 34 | 35 | r, ok := templates[page.Template] 36 | if !ok { 37 | fmt.Println("handler without template", page.Name, page.Template) 38 | continue 39 | } 40 | m.TemplateStore.Set(page.Template, r) 41 | if page.Layout == "" { 42 | fmt.Println("handler without layout", page.Name, page.Layout) 43 | continue 44 | } 45 | l, ok := templates[page.Layout] 46 | if !ok { 47 | fmt.Println("layout not defined", page.Layout) 48 | continue 49 | } 50 | m.TemplateStore.Set(page.Layout, l) 51 | 52 | m.TemplateStore.Set(fmt.Sprintf("%s-:-%s", h.Page.Layout, h.Page.Template), &LayoutMustacheRenderer{r.tmpl, l.tmpl}) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /engine/backend_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | var ( 14 | urlPattern = []byte("/test/:param") 15 | params = map[string]string{ 16 | "param": "replacetest", 17 | } 18 | headers = map[string]string{ 19 | "X-Test": "testing", 20 | } 21 | ) 22 | 23 | func TestNewBackend(t *testing.T) { 24 | gin.SetMode(gin.TestMode) 25 | mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 26 | fmt.Fprintln(w, "Hi") 27 | if v, ok := r.Header["X-Test"]; ok { 28 | if v[0] != "testing" { 29 | t.Error("Invalid header content.") 30 | } 31 | } 32 | if r.URL.RequestURI() != "/test/replacetest" { 33 | fmt.Println(r.URL.RequestURI()) 34 | t.Error("Invalid URL.") 35 | } 36 | })) 37 | defer mockServer.Close() 38 | backend := DefaultClient(fmt.Sprintf("%s%s", mockServer.URL, string(urlPattern))) 39 | context, _ := gin.CreateTestContext(httptest.NewRecorder()) 40 | resp, err := backend(params, headers, context) 41 | if err != nil { 42 | t.Errorf("Backend response error: %s", err.Error()) 43 | } 44 | if resp.StatusCode != 200 { 45 | t.Error("Invalid status code.") 46 | } 47 | } 48 | 49 | func TestReplaceParams(t *testing.T) { 50 | 51 | expectedResult := []byte("/test/replacetest") 52 | // Test empty params 53 | if !bytes.Equal(urlPattern, replaceParams(urlPattern, map[string]string{})) { 54 | t.Error("An empty param list should return the same URLPattern") 55 | } 56 | 57 | // Test replace params 58 | if !bytes.Equal(expectedResult, replaceParams(urlPattern, params)) { 59 | t.Error("The replace is not working as expected.") 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /cmd/serve.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/devopsfaith/api2html/engine" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var ( 13 | cfgFile string 14 | devel bool 15 | port int 16 | 17 | serveCmd = &cobra.Command{ 18 | Use: "serve", 19 | Short: "Run the api2html server.", 20 | Long: "Run the api2html server.", 21 | RunE: serveWrapper{defaultEngineFactory}.Serve, 22 | Aliases: []string{"run", "server", "start"}, 23 | Example: "api2html serve -d -c config.json", 24 | } 25 | 26 | errNilEngine = fmt.Errorf("serve cmd aborted: nil engine") 27 | ) 28 | 29 | func init() { 30 | rootCmd.AddCommand(serveCmd) 31 | 32 | serveCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "api2html.conf", "Path to the configuration filename") 33 | serveCmd.PersistentFlags().BoolVarP(&devel, "devel", "d", false, "Enable the devel") 34 | serveCmd.PersistentFlags().IntVarP(&port, "port", "p", 8080, "Listen port") 35 | } 36 | 37 | type engineWrapper interface { 38 | Run(...string) error 39 | } 40 | 41 | type engineFactory func(cfgPath string, devel bool) (engineWrapper, error) 42 | 43 | func defaultEngineFactory(cfgPath string, devel bool) (engineWrapper, error) { 44 | return engine.New(cfgPath, devel) 45 | } 46 | 47 | type serveWrapper struct { 48 | eF engineFactory 49 | } 50 | 51 | func (s serveWrapper) Serve(_ *cobra.Command, _ []string) error { 52 | eW, err := s.eF(cfgFile, devel) 53 | if err != nil { 54 | log.Println("engine creation aborted:", err.Error()) 55 | return err 56 | } 57 | if eW == nil { 58 | log.Println("engine creation aborted:", errNilEngine.Error()) 59 | return errNilEngine 60 | } 61 | 62 | time.Sleep(time.Second) 63 | 64 | return eW.Run(fmt.Sprintf(":%d", port)) 65 | } 66 | -------------------------------------------------------------------------------- /engine/backend.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/gregjones/httpcache" 9 | newrelic "github.com/newrelic/go-agent" 10 | nrgin "github.com/newrelic/go-agent/_integrations/nrgin/v1" 11 | ) 12 | 13 | var ( 14 | cachedTransport = httpcache.NewMemoryCacheTransport() 15 | cachedHTTPClient = http.Client{Transport: cachedTransport} 16 | ) 17 | 18 | // DefaultClient returns a Dackend to the received URLPattern with the default http client 19 | // from the stdlib 20 | func DefaultClient(URLPattern string) Backend { 21 | return NewBackend(http.DefaultClient, URLPattern) 22 | } 23 | 24 | // CachedClient returns a Dackend to the received URLPattern with a in-memory cache aware 25 | // http client 26 | func CachedClient(URLPattern string) Backend { 27 | return NewBackend(&cachedHTTPClient, URLPattern) 28 | } 29 | 30 | // NewBackend creates a Backend with the received http client and url pattern 31 | func NewBackend(client *http.Client, URLPattern string) Backend { 32 | urlPattern := []byte(URLPattern) 33 | actualTransport := client.Transport 34 | return func(params map[string]string, headers map[string]string, c *gin.Context) (*http.Response, error) { 35 | if newrelicApp != nil { 36 | defer newrelic.StartSegment(nrgin.Transaction(c), "Backend").End() 37 | client.Transport = newrelic.NewRoundTripper(nrgin.Transaction(c), actualTransport) 38 | } 39 | 40 | req, err := http.NewRequest("GET", string(replaceParams(urlPattern, params)), nil) 41 | if err != nil { 42 | return nil, err 43 | } 44 | for k, v := range headers { 45 | req.Header.Add(k, v) 46 | } 47 | return client.Do(req) 48 | } 49 | } 50 | 51 | func replaceParams(URLPattern []byte, params map[string]string) []byte { 52 | if len(params) == 0 { 53 | return URLPattern 54 | } 55 | buff := URLPattern 56 | for k, v := range params { 57 | key := []byte{} 58 | key = append(key, ":"...) 59 | key = append(key, k...) 60 | buff = bytes.Replace(buff, key, []byte(v), -1) 61 | } 62 | return buff 63 | } 64 | -------------------------------------------------------------------------------- /skeleton/skeleton_test.go: -------------------------------------------------------------------------------- 1 | package skeleton 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestNewBlogSkel(t *testing.T) { 11 | defaultSkel := NewBlog("test") 12 | if err := defaultSkel.Create(); err != nil { 13 | t.Errorf("Error creating skeleton files: %s", err.Error()) 14 | } 15 | defer os.RemoveAll("test") 16 | 17 | counter := 0 18 | err := filepath.Walk("test", func(path string, f os.FileInfo, err error) error { 19 | if _, err := os.Stat("test"); err != nil { 20 | return err 21 | } 22 | if !f.IsDir() { 23 | counter++ 24 | } 25 | return nil 26 | }) 27 | if err != nil { 28 | t.Errorf("Problem walking the test directory: %s", err.Error()) 29 | } 30 | if counter != 19 { 31 | t.Error("File count wrong, the test should have been generated 19 files.") 32 | } 33 | hellodata, _ := ioutil.ReadFile("test/blog/sources/global/static/hello.txt") 34 | expectedHello := string(`Hello, I am a text/plain content.`) 35 | if string(hellodata) != expectedHello { 36 | t.Error("Invalid content on hello.txt file.") 37 | } 38 | } 39 | 40 | func TestNewSkel(t *testing.T) { 41 | defaultSkel := New("test", []string{"/blog/i18n/es_ES.ini", "/blog/sources/global/static/hello.txt"}) 42 | if err := defaultSkel.Create(); err != nil { 43 | t.Errorf("Error creating skeleton files: %s", err.Error()) 44 | } 45 | defer os.RemoveAll("test") 46 | 47 | counter := 0 48 | err := filepath.Walk("test", func(path string, f os.FileInfo, err error) error { 49 | if _, err := os.Stat("test"); err != nil { 50 | return err 51 | } 52 | if !f.IsDir() { 53 | counter++ 54 | } 55 | return nil 56 | }) 57 | if err != nil { 58 | t.Errorf("Problem walking the test directory: %s", err.Error()) 59 | } 60 | if counter != 2 { 61 | t.Error("File count wrong, the test should have been generated 19 files.") 62 | } 63 | hellodata, _ := ioutil.ReadFile("test/blog/sources/global/static/hello.txt") 64 | expectedHello := string(`Hello, I am a text/plain content.`) 65 | if string(hellodata) != expectedHello { 66 | t.Error("Invalid content on hello.txt file.") 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /examples/blog/home.mustache: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Welcome to an example site

4 |

This site is powered by an API and all the source code is either configuration or templating. Enjoy!

5 |

You are seeing the home.mustache template

6 |
7 |
8 | 9 | {{ #Extra.debug }} 10 |
11 |
12 | {{> api2html/debug }} 13 |
14 |
15 | {{ /Extra.debug }} 16 | 17 |
18 | {{ #Array }} 19 |
20 |
21 |
22 | World 23 |

24 | {{ title }} 25 |

26 |
Written by user {{ idUser }}
27 |

{{ body }}

28 | Continue reading 29 |
30 | Thumbnail [200x250] 31 |
32 |
33 | {{ /Array }} 34 |
-------------------------------------------------------------------------------- /skeleton/files/blog/sources/global/tmpl/home.mustache: -------------------------------------------------------------------------------- 1 | {{=<% %>=}}
2 |
3 |

Welcome to an example site

4 |

This site is powered by an API and all the source code is either configuration or templating. Enjoy!

5 |

You are seeing the intro.mustache template

6 |
7 |
8 | 9 | 10 | {{ #Extra.debug }} 11 |
12 |
13 | {{> api2html/debug }} 14 |
15 |
16 | {{ /Extra.debug }} 17 | 18 |
19 | {{ #Array }} 20 |
21 |
22 |
23 | World 24 |

25 | {{ title }} 26 |

27 |
Written by user {{ idUser }}
28 |

{{ body }}

29 | Continue reading 30 |
31 | Thumbnail [200x250] 32 |
33 |
34 | {{ /Array }} 35 |
36 | -------------------------------------------------------------------------------- /skeleton/files/blog/sources/es_ES/tmpl/home.mustache: -------------------------------------------------------------------------------- 1 | {{=<% %>=}}
2 |
3 |

Bienvenido a la web de ejemplo!

4 |

Esta web usa los datos de una API and y todo el código es configuración y templates. A disfrutar!

5 |

Estás viendo el template intro.mustache

6 |
7 |
8 | 9 | 10 | {{ #Extra.debug }} 11 |
12 |
13 | {{> api2html/debug }} 14 |
15 |
16 | {{ /Extra.debug }} 17 | 18 |
19 | {{ #Array }} 20 |
21 |
22 |
23 | World 24 |

25 | {{ title }} 26 |

27 |
Escrito por el usuario {{ idUser }}
28 |

{{ body }}

29 | Continuar leyendo 30 |
31 | Thumbnail [200x250] 32 |
33 |
34 | {{ /Array }} 35 |
36 | -------------------------------------------------------------------------------- /generator/generator.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "regexp" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | type Generator interface { 12 | Generate(isos string) error 13 | } 14 | 15 | func New(basePath, ignoreRegex string) Generator { 16 | return &BasicGenerator{ 17 | SourceFolder: fmt.Sprintf("%s/sources", basePath), 18 | ConfigFolder: fmt.Sprintf("%s/config", basePath), 19 | I18NFolder: fmt.Sprintf("%s/i18n", basePath), 20 | OutputFolder: fmt.Sprintf("%s/output", basePath), 21 | IgnorePattern: ignoreRegex, 22 | ScannerFactory: NewScanner, 23 | CollectorFactory: NewCollector, 24 | RendererFactory: NewRenderer, 25 | } 26 | } 27 | 28 | type BasicGenerator struct { 29 | SourceFolder string 30 | I18NFolder string 31 | ConfigFolder string 32 | OutputFolder string 33 | IgnorePattern string 34 | ScannerFactory func([]string) Scanner 35 | CollectorFactory func(string, string) Collector 36 | RendererFactory func(string, *regexp.Regexp) Renderer 37 | } 38 | 39 | func (g *BasicGenerator) Generate(isos string) error { 40 | collector := g.CollectorFactory(g.ConfigFolder, g.I18NFolder) 41 | renderer := g.RendererFactory(g.OutputFolder, regexp.MustCompile(g.IgnorePattern)) 42 | 43 | if isos == "*" { 44 | isos = strings.Join(collector.AvailableISOs(), ",") 45 | } 46 | 47 | for _, iso := range strings.Split(isos, ",") { 48 | start := time.Now() 49 | log.Printf("[%s] generating the site", iso) 50 | 51 | data, err := collector.Collect(iso) 52 | if err != nil { 53 | log.Println(err.Error()) 54 | return err 55 | } 56 | 57 | log.Printf("[%s] all translations and configurations collected", iso) 58 | log.Printf("[%s] %v", iso, data) 59 | 60 | scanner := g.ScannerFactory([]string{ 61 | fmt.Sprintf("%s/global", g.SourceFolder), 62 | fmt.Sprintf("%s/%s", g.SourceFolder, iso), 63 | }) 64 | 65 | if err := renderer.Render(iso, data, scanner); err != nil { 66 | log.Printf("[%s] error: %s", iso, err.Error()) 67 | return err 68 | } 69 | 70 | log.Println("****************************************") 71 | log.Printf("[%s] site generated! time: %s", iso, time.Since(start).String()) 72 | log.Println("****************************************") 73 | } 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /engine/template_store.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import "sync" 4 | 5 | // NewTemplateStore creates a TemplateStore ready to be used 6 | // 7 | // The returned TemplateStore will be accepting and managing 8 | // subscriptions 9 | func NewTemplateStore() *TemplateStore { 10 | store := &TemplateStore{ 11 | &templateStore{ 12 | map[string]Renderer{}, 13 | map[string]*sync.RWMutex{}, 14 | }, 15 | make(chan Subscription), 16 | &sync.Map{}, 17 | } 18 | go store.subscribe() 19 | return store 20 | } 21 | 22 | // TemplateStore manages the loaded templates and the subscriptions 23 | type TemplateStore struct { 24 | *templateStore 25 | Subscribe chan Subscription 26 | observers *sync.Map 27 | } 28 | 29 | func (p *TemplateStore) subscribe() { 30 | for { 31 | subscription := <-p.Subscribe 32 | actual, loaded := p.observers.LoadOrStore(subscription.Name, []chan Renderer{subscription.In}) 33 | if loaded { 34 | chans := actual.([]chan Renderer) 35 | p.observers.Store(subscription.Name, append(chans, subscription.In)) 36 | } 37 | } 38 | } 39 | 40 | // Set adds or updates the renderer with the given name. After updating its internal state, it 41 | // alerts all the subscriptors by sending the new renderer and removes all the subscriptions. 42 | func (p *TemplateStore) Set(name string, tmpl Renderer) error { 43 | if err := p.templateStore.Set(name, tmpl); err != nil { 44 | return err 45 | } 46 | 47 | if actual, ok := p.observers.Load(name); ok { 48 | r := p.data[name] 49 | chans := actual.([]chan Renderer) 50 | for _, out := range chans { 51 | out <- r 52 | } 53 | } 54 | 55 | p.observers.Store(name, []chan Renderer{}) 56 | return nil 57 | } 58 | 59 | type templateStore struct { 60 | data map[string]Renderer 61 | mutex map[string]*sync.RWMutex 62 | } 63 | 64 | // Get returns a Renderer and a boolean signaling if the given name is not in the store 65 | func (p *templateStore) Get(name string) (Renderer, bool) { 66 | m := p.getMutex(name) 67 | m.RLock() 68 | defer m.RUnlock() 69 | t, ok := p.data[name] 70 | return t, ok 71 | } 72 | 73 | func (p *templateStore) Set(name string, tmpl Renderer) error { 74 | m := p.getMutex(name) 75 | m.Lock() 76 | p.data[name] = tmpl 77 | m.Unlock() 78 | return nil 79 | } 80 | 81 | func (p *templateStore) getMutex(name string) *sync.RWMutex { 82 | m, ok := p.mutex[name] 83 | if !ok { 84 | m = &sync.RWMutex{} 85 | p.mutex[name] = m 86 | } 87 | return m 88 | } 89 | -------------------------------------------------------------------------------- /generator/collector_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestNewCollector(t *testing.T) { 10 | pwd := os.Getenv("PWD") 11 | 12 | collector := NewCollector("unknownConfig1", "unknownI18N1") 13 | availableISOs := collector.AvailableISOs() 14 | expectedSize := 0 15 | if len(availableISOs) != expectedSize { 16 | t.Errorf("unexpected size for availableISOs. have %d, want %d", len(availableISOs), expectedSize) 17 | } 18 | _, err := collector.Collect("unknown_iso_1") 19 | if err == nil { 20 | t.Error("expecting error!") 21 | return 22 | } 23 | if err.Error() != "open unknownI18N1/unknown_iso_1.ini: no such file or directory" { 24 | t.Error("collecting from unknown translation folder", err) 25 | return 26 | } 27 | 28 | collector = NewCollector("unknownConfig2", fmt.Sprintf("%s/test/i18n", pwd)) 29 | availableISOs = collector.AvailableISOs() 30 | expectedSize = 3 31 | if len(availableISOs) != expectedSize { 32 | t.Errorf("unexpected size for availableISOs. have %d, want %d", len(availableISOs), expectedSize) 33 | } 34 | _, err = collector.Collect("en-US") 35 | if err != ErrNoConfig { 36 | t.Error("expecting ErrNoConfig. got:", err) 37 | return 38 | } 39 | 40 | collector = NewCollector(fmt.Sprintf("%s/test/config", pwd), fmt.Sprintf("%s/test/i18n", pwd)) 41 | availableISOs = collector.AvailableISOs() 42 | expectedSize = 3 43 | if len(availableISOs) != expectedSize { 44 | t.Errorf("unexpected size for availableISOs. have %d, want %d", len(availableISOs), expectedSize) 45 | } 46 | 47 | for _, iso := range collector.AvailableISOs() { 48 | data, err := collector.Collect(iso) 49 | if err != nil { 50 | t.Error("collecting", iso, err) 51 | return 52 | } 53 | fmt.Println(iso, data) 54 | } 55 | } 56 | 57 | func TestData_String(t *testing.T) { 58 | data := Data{ 59 | Config: map[string]Map{ 60 | "cfg1": { 61 | "key1": "value1", 62 | "key2": "value2", 63 | }, 64 | }, 65 | I18N: map[string]Map{ 66 | "cfg1": { 67 | "literal1": "literal_value_1", 68 | }, 69 | }, 70 | } 71 | if data.String() != `{"I18N":{"cfg1":{"literal1":"literal_value_1"}},"Config":{"cfg1":{"key1":"value1","key2":"value2"}}}` { 72 | t.Error("unexpected data serialization result:", data) 73 | } 74 | if data.Config["cfg1"].String() != `{"key1":"value1","key2":"value2"}` { 75 | t.Error("unexpected config serialization result:", data) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /skeleton/files/blog/sources/global/tmpl/main_layout.mustache: -------------------------------------------------------------------------------- 1 | {{=<% %>=}} 2 | 3 | 4 | 5 | 6 | API2HTML Demo 7 | 8 | 9 |
10 |
11 |
12 |
13 | Subscribe 14 |
15 |
16 |

API2HTML

17 |
18 |
19 | 20 | 21 | 22 | Sign up 23 |
24 |
25 |
26 | 42 | 43 |
44 | 45 | {{{ content }}} 46 | 47 |
48 |
49 |

Back to top

50 |

The layout of this page is under main_layout.mustache

51 |

{{ Extra.copyright }} · Privacy · Terms

52 |
53 |
54 | 55 | 56 | -------------------------------------------------------------------------------- /cmd/serve_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "testing" 9 | 10 | "github.com/devopsfaith/api2html/engine" 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | func Test_defaultEngineFactory(t *testing.T) { 15 | cfg := engine.Config{} 16 | f, err := ioutil.TempFile(".", "") 17 | if err != nil { 18 | t.Error(err) 19 | return 20 | } 21 | if jerr := json.NewEncoder(f).Encode(&cfg); jerr != nil { 22 | t.Error(jerr) 23 | return 24 | } 25 | f.Close() 26 | 27 | defer os.Remove(f.Name()) 28 | 29 | g, err := defaultEngineFactory(f.Name(), true) 30 | if err != nil { 31 | t.Errorf("getting the default engine: %s", err.Error()) 32 | return 33 | } 34 | switch g.(type) { 35 | case *gin.Engine: 36 | default: 37 | t.Errorf("unexpected engine type: %T", g) 38 | } 39 | } 40 | 41 | func Test_serveWrapper_koErroredEngineFactory(t *testing.T) { 42 | expectedError := fmt.Errorf("expect me") 43 | subject := serveWrapper{erroredEngineFactory(expectedError)} 44 | 45 | if err := subject.Serve(nil, []string{}); err == nil { 46 | t.Error("expecting error!") 47 | return 48 | } else if err != expectedError { 49 | t.Errorf("unexpected error! want: %s, got: %s", expectedError.Error(), err.Error()) 50 | return 51 | } 52 | } 53 | 54 | func Test_serveWrapper_koErroredEngine(t *testing.T) { 55 | subject := serveWrapper{customEngineFactory(nil)} 56 | 57 | if err := subject.Serve(nil, []string{}); err == nil { 58 | t.Error("expecting error!") 59 | return 60 | } else if err != errNilEngine { 61 | t.Errorf("unexpected error! want: %s, got: %s", errNilEngine.Error(), err.Error()) 62 | return 63 | } 64 | 65 | expectedError := fmt.Errorf("expect me") 66 | subject = serveWrapper{customEngineFactory(erroredEngine{expectedError})} 67 | 68 | if err := subject.Serve(nil, []string{}); err == nil { 69 | t.Error("expecting error!") 70 | } else if err != expectedError { 71 | t.Errorf("unexpected error! want: %s, got: %s", expectedError.Error(), err.Error()) 72 | } 73 | } 74 | 75 | func erroredEngineFactory(err error) engineFactory { 76 | return func(_ string, _ bool) (engineWrapper, error) { return nil, err } 77 | } 78 | 79 | func customEngineFactory(e engineWrapper) engineFactory { 80 | return func(_ string, _ bool) (engineWrapper, error) { return e, nil } 81 | } 82 | 83 | type erroredEngine struct { 84 | err error 85 | } 86 | 87 | func (e erroredEngine) Run(_ ...string) error { 88 | return e.err 89 | } 90 | -------------------------------------------------------------------------------- /examples/blog/main_layout.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | API2HTML Demo 7 | 8 | 9 |
10 |
11 |
12 |
13 | Subscribe 14 |
15 |
16 |

API2HTML

17 |
18 |
19 | 20 | 21 | 22 | Sign up 23 |
24 |
25 |
26 | 42 | 43 |
44 | 45 | {{{ content }}} 46 | 47 |
48 |
49 |

Back to top

50 |

The layout of this page is under main_layout.mustache

51 |

{{ Extra.copyright }} · Privacy · Terms

52 |
53 |
54 |
55 |
56 | {{> api2html/debug }} 57 |
58 |
59 | 60 | 61 | -------------------------------------------------------------------------------- /skeleton/skeleton.go: -------------------------------------------------------------------------------- 1 | package skeleton 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | 10 | // Import the statikFS from the generated files 11 | _ "github.com/devopsfaith/api2html/statik" 12 | "github.com/rakyll/statik/fs" 13 | ) 14 | 15 | var ( 16 | blogContents = []string{ 17 | "/blog/config/es_ES/config.ini", 18 | "/blog/config/es_ES/routes.ini", 19 | "/blog/config/global/config.ini", 20 | "/blog/config/global/routes.ini", 21 | "/blog/i18n/es_ES.ini", 22 | "/blog/i18n/en_US.ini", 23 | "/blog/sources/es_ES/static/404", 24 | "/blog/sources/es_ES/static/500", 25 | "/blog/sources/es_ES/tmpl/home.mustache", 26 | "/blog/sources/global/config.json", 27 | "/blog/sources/global/Dockerfile", 28 | "/blog/sources/global/static/404", 29 | "/blog/sources/global/static/500", 30 | "/blog/sources/global/static/hello.txt", 31 | "/blog/sources/global/static/robots.txt", 32 | "/blog/sources/global/static/sitemap.xml", 33 | "/blog/sources/global/tmpl/home.mustache", 34 | "/blog/sources/global/tmpl/main_layout.mustache", 35 | "/blog/sources/global/tmpl/post.mustache", 36 | } 37 | ) 38 | 39 | // New returns a statikFS Skel 40 | func New(outputPath string, fileList []string) Skel { 41 | return &statikSkel{outputPath: outputPath, fileList: fileList} 42 | } 43 | 44 | // NewBlog returns a statikSkel with the blog example contents 45 | func NewBlog(outputPath string) Skel { 46 | return &statikSkel{outputPath: outputPath, fileList: blogContents} 47 | } 48 | 49 | // Skel defines the interface for creating skeleton files 50 | type Skel interface { 51 | Create() error 52 | } 53 | 54 | type statikSkel struct { 55 | outputPath string 56 | fileList []string 57 | } 58 | 59 | // Generate the skel from the statikFS 60 | func (s *statikSkel) Create() error { 61 | statikFS, err := fs.New() 62 | if err != nil { 63 | return err 64 | } 65 | 66 | for _, name := range s.fileList { 67 | f, err := statikFS.Open(name) 68 | if err != nil { 69 | fmt.Printf("opening file %s: %s\n", name, err.Error()) 70 | return err 71 | } 72 | buff := new(bytes.Buffer) 73 | _, err = buff.ReadFrom(f) 74 | f.Close() 75 | if err != nil { 76 | return err 77 | } 78 | path := filepath.Join(s.outputPath, filepath.Dir(name)) 79 | if _, err := os.Stat(path); os.IsNotExist(err) { 80 | err = os.MkdirAll(path, os.ModePerm) 81 | if err != nil { 82 | return err 83 | } 84 | } 85 | filename := filepath.Join(s.outputPath, name) 86 | err = ioutil.WriteFile(filename, buff.Bytes(), os.ModePerm) 87 | if err != nil { 88 | return err 89 | } 90 | fmt.Printf("Creating skeleton file: %s\n", filename) 91 | } 92 | return nil 93 | } 94 | -------------------------------------------------------------------------------- /generator/render_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "reflect" 9 | "regexp" 10 | "strings" 11 | "testing" 12 | ) 13 | 14 | func TestRender(t *testing.T) { 15 | dir, err := ioutil.TempDir("test", "render_output") 16 | if err != nil { 17 | log.Fatal(err) 18 | } 19 | 20 | defer os.RemoveAll(dir) // clean up 21 | 22 | data := Data{ 23 | Config: map[string]Map{ 24 | "cfg1": { 25 | "key1": "v1", 26 | }, 27 | }, 28 | I18N: map[string]Map{ 29 | "I18N1": { 30 | "I18N1-1": "v1", 31 | }, 32 | }, 33 | } 34 | 35 | expectedErr := fmt.Errorf("some expected error") 36 | render := Render{ 37 | OutputFolder: dir, 38 | Regexp: nil, 39 | Dumper: func(_, _ string, _ Data) error { 40 | return expectedErr 41 | }, 42 | } 43 | if err := render.Render("iso", data, dummyScanner{TmplFolder{Path: "path", Content: []string{"some_tmpl", "ignore_me"}}}); err != expectedErr { 44 | t.Error("render error:", err) 45 | } 46 | 47 | var total int 48 | render = Render{ 49 | OutputFolder: dir, 50 | Regexp: regexp.MustCompile("ignore"), 51 | Dumper: func(source, target string, d Data) error { 52 | if source != "path/some_tmpl" { 53 | t.Error("unexpected source:", source) 54 | } 55 | if !strings.Contains(target, "/iso/some_tmpl") || !strings.Contains(target, "test/render_output") { 56 | t.Error("unexpected target:", target) 57 | } 58 | if !reflect.DeepEqual(data, d) { 59 | t.Error("unexpected data:", d) 60 | } 61 | total++ 62 | return nil 63 | }, 64 | } 65 | 66 | if err := render.Render("iso", data, dummyScanner{TmplFolder{Path: "empty_path"}}); err != nil { 67 | t.Error("render error:", err) 68 | } 69 | 70 | if total != 0 { 71 | t.Errorf("unexpected number of calls to the dumper. have %d, want %d", total, 0) 72 | } 73 | 74 | if err := render.Render("iso", data, dummyScanner{TmplFolder{Path: "path", Content: []string{"some_tmpl", "ignore_me"}}}); err != nil { 75 | t.Error("render error:", err) 76 | } 77 | 78 | if total != 1 { 79 | t.Errorf("unexpected number of calls to the dumper. have %d, want %d", total, 1) 80 | } 81 | } 82 | 83 | // func TestRender(t *testing.T) { 84 | // dir, err := ioutil.TempDir("test", "render_output") 85 | // if err != nil { 86 | // log.Fatal(err) 87 | // } 88 | 89 | // defer os.RemoveAll(dir) // clean up 90 | 91 | // render := NewRender(dir, regexp.MustCompile("ignore")) 92 | 93 | // tmpfn := filepath.Join(dir, "tmpfile") 94 | // if err := ioutil.WriteFile(tmpfn, content, 0666); err != nil { 95 | // log.Fatal(err) 96 | // } 97 | // } 98 | 99 | type dummyScanner []TmplFolder 100 | 101 | func (d dummyScanner) Scan() []TmplFolder { 102 | return d 103 | } 104 | -------------------------------------------------------------------------------- /generator/render.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "os" 8 | "regexp" 9 | 10 | "github.com/cbroglie/mustache" 11 | ) 12 | 13 | type Renderer interface { 14 | Render(string, Data, Scanner) error 15 | } 16 | 17 | func NewRenderer(outputFolder string, r *regexp.Regexp) Renderer { 18 | return &Render{ 19 | OutputFolder: outputFolder, 20 | Regexp: r, 21 | Dumper: mustacheRender, 22 | } 23 | } 24 | 25 | type Dumper func(string, string, Data) error 26 | 27 | type Render struct { 28 | OutputFolder string 29 | Regexp *regexp.Regexp 30 | Dumper Dumper 31 | } 32 | 33 | func (r *Render) Render(iso string, data Data, scanner Scanner) error { 34 | if err := r.prepareOutputFolder(iso); err != nil { 35 | return err 36 | } 37 | for _, tmplFolder := range scanner.Scan() { 38 | if len(tmplFolder.Content) == 0 { 39 | log.Println("skipping the empty folder:", tmplFolder.Path) 40 | continue 41 | } 42 | for _, tmplName := range tmplFolder.Content { 43 | source := fmt.Sprintf("%s/%s", tmplFolder.Path, tmplName) 44 | target := fmt.Sprintf("%s/%s/%s", r.OutputFolder, iso, tmplName) 45 | 46 | if r.Regexp != nil && r.Regexp.Match([]byte(tmplName)) { 47 | log.Println("ignoring the source file:", source) 48 | continue 49 | } 50 | 51 | if err := r.Dumper(source, target, data); err != nil { 52 | log.Printf("rendering [%s] into [%s]: %s", source, target, err.Error()) 53 | return err 54 | } 55 | } 56 | log.Println(tmplFolder.Path, "preprocessed") 57 | } 58 | return nil 59 | } 60 | 61 | func (r *Render) prepareOutputFolder(iso string) error { 62 | target := fmt.Sprintf("%s/%s", r.OutputFolder, iso) 63 | if err := os.RemoveAll(target); err != nil { 64 | return err 65 | } 66 | 67 | if err := os.MkdirAll(fmt.Sprintf("%s/tmpl", target), os.ModePerm); err != nil { 68 | return err 69 | } 70 | 71 | return os.MkdirAll(fmt.Sprintf("%s/static", target), os.ModePerm) 72 | } 73 | 74 | func mustacheRender(source, target string, data Data) error { 75 | if _, err := os.Stat(source); os.IsNotExist(err) { 76 | log.Println(source, "doesn't exist. Skipping.") 77 | return nil 78 | } 79 | 80 | log.Println("parsing the file", source) 81 | 82 | tmpl, err := mustache.ParseFile(source) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | log.Println("writing the contents of", source, "into", target) 88 | 89 | file, err := os.OpenFile(target, os.O_RDWR|os.O_CREATE, 0644) 90 | if err != nil { 91 | return err 92 | } 93 | defer file.Close() 94 | 95 | buf := bytes.NewBuffer([]byte{}) 96 | 97 | if err := tmpl.FRender(buf, data); err != nil { 98 | return err 99 | } 100 | 101 | file.Write(bytes.Replace(buf.Bytes(), []byte("""), []byte(`"`), -1)) 102 | 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /cmd/generate.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "time" 7 | 8 | "github.com/devopsfaith/api2html/generator" 9 | "github.com/fsnotify/fsnotify" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var ( 14 | isos string 15 | basePath string 16 | ignoreRegex string 17 | 18 | generateCmd = &cobra.Command{ 19 | Use: "generate", 20 | Short: "Generate the final api2html templates.", 21 | Long: "Generate the final api2html templates.", 22 | RunE: generatorWrapper{defaultGeneratorFactory}.Generate, 23 | Aliases: []string{"create", "new"}, 24 | Example: "api2html generate -i en_US -r partial", 25 | } 26 | 27 | generateAndWatchCmd = &cobra.Command{ 28 | Use: "watch", 29 | Short: "Generate the final api2html templates after every change in the target.", 30 | Long: "Generate the final api2html templates after every change in the target.", 31 | RunE: generatorWatchWrapper{generatorWrapper{defaultGeneratorFactory}}.Watch, 32 | Example: "api2html generate watch -i en_US -r partial", 33 | } 34 | ) 35 | 36 | func init() { 37 | rootCmd.AddCommand(generateCmd) 38 | 39 | generateCmd.PersistentFlags().StringVarP(&basePath, "path", "p", os.Getenv("PWD"), "Base path for the generation") 40 | generateCmd.PersistentFlags().StringVarP(&isos, "iso", "i", "*", "(comma-separated) iso code of the site to create") 41 | generateCmd.PersistentFlags().StringVarP(&ignoreRegex, "reg", "r", "ignore", "regex filtering the sources to move to the output folder") 42 | 43 | generateCmd.AddCommand(generateAndWatchCmd) 44 | } 45 | 46 | type generatorFactory func(basePath string, ignoreRegex string) generator.Generator 47 | 48 | func defaultGeneratorFactory(basePath string, ignoreRegex string) generator.Generator { 49 | return generator.New(basePath, ignoreRegex) 50 | } 51 | 52 | type generatorWrapper struct { 53 | gf generatorFactory 54 | } 55 | 56 | func (g generatorWrapper) Generate(_ *cobra.Command, _ []string) error { 57 | start := time.Now() 58 | 59 | if err := g.gf(basePath, ignoreRegex).Generate(isos); err != nil { 60 | log.Println("generation aborted:", err.Error()) 61 | return err 62 | } 63 | 64 | log.Println("site generated! time:", time.Since(start)) 65 | return nil 66 | } 67 | 68 | type generatorWatchWrapper struct { 69 | generatorWrapper 70 | } 71 | 72 | func (g generatorWatchWrapper) Watch(c *cobra.Command, p []string) error { 73 | if err := g.Generate(c, p); err != nil { 74 | return err 75 | } 76 | 77 | watcher, err := fsnotify.NewWatcher() 78 | if err != nil { 79 | return err 80 | } 81 | defer watcher.Close() 82 | 83 | err = watcher.Add(basePath) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | for { 89 | select { 90 | case event := <-watcher.Events: 91 | log.Println("event:", event) 92 | if err := g.Generate(c, p); err != nil { 93 | return err 94 | } 95 | case err := <-watcher.Errors: 96 | return err 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /engine/mustache.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "io" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | 9 | "github.com/cbroglie/mustache" 10 | ) 11 | 12 | // NewMustacheRendererMap returns a map with all renderers for the declared templates and layouts 13 | // and an error if something went wrong 14 | func NewMustacheRendererMap(cfg Config) (map[string]*MustacheRenderer, error) { 15 | result := map[string]*MustacheRenderer{} 16 | for _, section := range []map[string]string{cfg.Templates, cfg.Layouts} { 17 | for name, path := range section { 18 | templateFile, err := os.Open(path) 19 | if err != nil { 20 | log.Println("reading", path, ":", err.Error()) 21 | return result, err 22 | } 23 | renderer, err := NewMustacheRenderer(templateFile) 24 | templateFile.Close() 25 | if err != nil { 26 | log.Println("parsing", path, ":", err.Error()) 27 | return result, err 28 | } 29 | result[name] = renderer 30 | } 31 | } 32 | return result, nil 33 | } 34 | 35 | // NewMustacheRenderer returns a MustacheRenderer and an error if something went wrong 36 | func NewMustacheRenderer(r io.Reader) (*MustacheRenderer, error) { 37 | tmpl, err := newMustacheTemplate(r) 38 | if err != nil { 39 | return nil, err 40 | } 41 | return &MustacheRenderer{tmpl}, nil 42 | } 43 | 44 | // MustacheRenderer is a simple mustache renderer with a single mustache template 45 | type MustacheRenderer struct { 46 | tmpl *mustache.Template 47 | } 48 | 49 | // Render implements the renderer interface 50 | func (m MustacheRenderer) Render(w io.Writer, v interface{}) error { 51 | return m.tmpl.FRender(w, v) 52 | } 53 | 54 | // NewLayoutMustacheRenderer returns a LayoutMustacheRenderer and an error if something went wrong 55 | func NewLayoutMustacheRenderer(t, l io.Reader) (*LayoutMustacheRenderer, error) { 56 | tmpl, err := newMustacheTemplate(t) 57 | if err != nil { 58 | return nil, err 59 | } 60 | layout, err := newMustacheTemplate(l) 61 | if err != nil { 62 | return nil, err 63 | } 64 | return &LayoutMustacheRenderer{tmpl, layout}, nil 65 | } 66 | 67 | // LayoutMustacheRenderer is a mustache renderer composing a mustache template with a layout 68 | type LayoutMustacheRenderer struct { 69 | tmpl *mustache.Template 70 | layout *mustache.Template 71 | } 72 | 73 | // Render implements the renderer interface 74 | func (m LayoutMustacheRenderer) Render(w io.Writer, v interface{}) error { 75 | return m.tmpl.FRenderInLayout(w, m.layout, v) 76 | } 77 | 78 | func newMustacheTemplate(r io.Reader) (*mustache.Template, error) { 79 | data, err := ioutil.ReadAll(r) 80 | if err != nil { 81 | return nil, err 82 | } 83 | return mustache.ParseStringPartials(string(data), customPartialProvider) 84 | } 85 | 86 | type partialProvider struct { 87 | statics mustache.PartialProvider 88 | dynamc mustache.PartialProvider 89 | } 90 | 91 | func (sp *partialProvider) Get(name string) (string, error) { 92 | if data, err := sp.statics.Get(name); err == nil && data != "" { 93 | return data, nil 94 | } 95 | 96 | return sp.dynamc.Get(name) 97 | } 98 | 99 | var ( 100 | partials = map[string]string{ 101 | "api2html/debug": debuggerTmpl, 102 | } 103 | customPartialProvider = &partialProvider{ 104 | dynamc: &mustache.FileProvider{}, 105 | statics: &mustache.StaticProvider{Partials: partials}, 106 | } 107 | ) 108 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "master" 6 | name = "github.com/Unknwon/goconfig" 7 | packages = ["."] 8 | revision = "87a46d97951ee1ea20ed3b24c25646a79e87ba5d" 9 | 10 | [[projects]] 11 | name = "github.com/cbroglie/mustache" 12 | packages = ["."] 13 | revision = "2eb171290cbdc792c57bcd10f5a030b53500b28f" 14 | version = "v1.0.0" 15 | 16 | [[projects]] 17 | name = "github.com/fsnotify/fsnotify" 18 | packages = ["."] 19 | revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" 20 | version = "v1.4.7" 21 | 22 | [[projects]] 23 | branch = "master" 24 | name = "github.com/gin-contrib/sse" 25 | packages = ["."] 26 | revision = "22d885f9ecc78bf4ee5d72b937e4bbcdc58e8cae" 27 | 28 | [[projects]] 29 | branch = "master" 30 | name = "github.com/gin-contrib/static" 31 | packages = ["."] 32 | revision = "e6b7eb92648d0a5faeea81700668a7a5dbe8424f" 33 | 34 | [[projects]] 35 | name = "github.com/gin-gonic/gin" 36 | packages = [ 37 | ".", 38 | "binding", 39 | "render" 40 | ] 41 | revision = "d459835d2b077e44f7c9b453505ee29881d5d12d" 42 | version = "v1.2" 43 | 44 | [[projects]] 45 | name = "github.com/golang/protobuf" 46 | packages = ["proto"] 47 | revision = "925541529c1fa6821df4e44ce2723319eb2be768" 48 | version = "v1.0.0" 49 | 50 | [[projects]] 51 | branch = "master" 52 | name = "github.com/gregjones/httpcache" 53 | packages = ["."] 54 | revision = "2bcd89a1743fd4b373f7370ce8ddc14dfbd18229" 55 | 56 | [[projects]] 57 | name = "github.com/inconshreveable/mousetrap" 58 | packages = ["."] 59 | revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" 60 | version = "v1.0" 61 | 62 | [[projects]] 63 | name = "github.com/mattn/go-isatty" 64 | packages = ["."] 65 | revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" 66 | version = "v0.0.3" 67 | 68 | [[projects]] 69 | name = "github.com/newrelic/go-agent" 70 | packages = [ 71 | ".", 72 | "_integrations/nrgin/v1", 73 | "internal", 74 | "internal/cat", 75 | "internal/jsonx", 76 | "internal/logger", 77 | "internal/sysinfo", 78 | "internal/utilization" 79 | ] 80 | revision = "f5bce3387232559bcbe6a5f8227c4bf508dac1ba" 81 | version = "v1.11.0" 82 | 83 | [[projects]] 84 | name = "github.com/rakyll/statik" 85 | packages = ["fs"] 86 | revision = "fd36b3595eb2ec8da4b8153b107f7ea08504899d" 87 | version = "v0.1.1" 88 | 89 | [[projects]] 90 | name = "github.com/spf13/cobra" 91 | packages = ["."] 92 | revision = "7b2c5ac9fc04fc5efafb60700713d4fa609b777b" 93 | version = "v0.0.1" 94 | 95 | [[projects]] 96 | name = "github.com/spf13/pflag" 97 | packages = ["."] 98 | revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66" 99 | version = "v1.0.0" 100 | 101 | [[projects]] 102 | name = "github.com/ugorji/go" 103 | packages = ["codec"] 104 | revision = "9831f2c3ac1068a78f50999a30db84270f647af6" 105 | version = "v1.1" 106 | 107 | [[projects]] 108 | branch = "master" 109 | name = "golang.org/x/sys" 110 | packages = ["unix"] 111 | revision = "37707fdb30a5b38865cfb95e5aab41707daec7fd" 112 | 113 | [[projects]] 114 | name = "gopkg.in/go-playground/validator.v8" 115 | packages = ["."] 116 | revision = "5f1438d3fca68893a817e4a66806cea46a9e4ebf" 117 | version = "v8.18.2" 118 | 119 | [[projects]] 120 | branch = "v2" 121 | name = "gopkg.in/yaml.v2" 122 | packages = ["."] 123 | revision = "d670f9405373e636a5a2765eea47fac0c9bc91a4" 124 | 125 | [solve-meta] 126 | analyzer-name = "dep" 127 | analyzer-version = 1 128 | inputs-digest = "8e6050f7a3e710c723ef92b37b5b3050692a8a8fc57a30cccb31b34fc7f3d1cf" 129 | solver-name = "gps-cdcl" 130 | solver-version = 1 131 | -------------------------------------------------------------------------------- /engine/mustache_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "math/rand" 8 | "os" 9 | "testing" 10 | "testing/iotest" 11 | ) 12 | 13 | func TestMustachePartials(t *testing.T) { 14 | tmpl, err := customPartialProvider.Get("api2html/debug") 15 | if err != nil { 16 | t.Error(err) 17 | } 18 | if tmpl == "" { 19 | t.Error("empty partial") 20 | } 21 | tmpl, err = customPartialProvider.Get("_____unnknown_____") 22 | if err != nil { 23 | t.Error(err) 24 | } 25 | if tmpl != "" { 26 | t.Error("unexpected result:", tmpl) 27 | } 28 | } 29 | 30 | func TestNewMustacheRenderer_ok(t *testing.T) { 31 | tmpl, err := NewMustacheRenderer(bytes.NewBufferString(`-{{ a }}-`)) 32 | if err != nil { 33 | t.Error(err) 34 | return 35 | } 36 | 37 | if err := checkRenderer(tmpl); err != nil { 38 | t.Error(err) 39 | } 40 | } 41 | 42 | func TestNewMustacheRenderer_ko(t *testing.T) { 43 | _, err := NewMustacheRenderer(bytes.NewBufferString(`-{{ a `)) 44 | if err == nil { 45 | t.Error("expecting error") 46 | } 47 | } 48 | 49 | func TestNewLayoutMustacheRenderer_ok(t *testing.T) { 50 | tmpl, err := NewLayoutMustacheRenderer(bytes.NewBufferString(`{{ a }}`), bytes.NewBufferString(`-{{{ content }}}-`)) 51 | if err != nil { 52 | t.Error(err) 53 | return 54 | } 55 | 56 | if err := checkRenderer(tmpl); err != nil { 57 | t.Error(err) 58 | } 59 | } 60 | 61 | func TestNewLayoutMustacheRenderer_ko(t *testing.T) { 62 | _, err := NewLayoutMustacheRenderer(bytes.NewBufferString(`{{ a `), bytes.NewBufferString(`-{{{ content }}}-`)) 63 | if err == nil { 64 | t.Error("expecting error") 65 | } 66 | _, err = NewLayoutMustacheRenderer(bytes.NewBufferString(`{{ a }}`), bytes.NewBufferString(`-{{{ content -`)) 67 | if err == nil { 68 | t.Error("expecting error") 69 | } 70 | } 71 | 72 | func TestNewMustacheRendererMap_ok(t *testing.T) { 73 | layoutPath := "a_layout.mustache" 74 | templatePath := "template.mustache" 75 | ioutil.WriteFile(layoutPath, []byte(`-{{{ content }}}-`), 0666) 76 | ioutil.WriteFile(templatePath, []byte(`-{{ a }}-`), 0666) 77 | renderers, err := NewMustacheRendererMap(Config{ 78 | Templates: map[string]string{"t": templatePath}, 79 | Layouts: map[string]string{"l": layoutPath}, 80 | }) 81 | defer os.Remove(layoutPath) 82 | defer os.Remove(templatePath) 83 | if err != nil { 84 | t.Error(err) 85 | return 86 | } 87 | if _, ok := renderers["l"]; !ok { 88 | t.Error("layout renderer not found in the map") 89 | } 90 | tTmpl, ok := renderers["t"] 91 | if !ok { 92 | t.Error("template renderer not found in the map") 93 | } 94 | 95 | if err := checkRenderer(tTmpl); err != nil { 96 | t.Error(err) 97 | } 98 | } 99 | 100 | func TestNewMustacheRendererMap_koBadTemplate(t *testing.T) { 101 | layoutPath := "a_layout.mustache" 102 | ioutil.WriteFile(layoutPath, []byte(`-{{{ content`), 0666) 103 | _, err := NewMustacheRendererMap(Config{ 104 | Layouts: map[string]string{"l": layoutPath}, 105 | }) 106 | defer os.Remove(layoutPath) 107 | if err == nil { 108 | t.Error("expecting error!") 109 | return 110 | } 111 | } 112 | 113 | func TestNewMustacheRendererMap_koNoFile(t *testing.T) { 114 | _, err := NewMustacheRendererMap(Config{ 115 | Templates: map[string]string{"unknown": "unknown"}, 116 | }) 117 | if err == nil { 118 | t.Error("expecting error!") 119 | return 120 | } 121 | } 122 | 123 | func Test_newMustacheTemplate(t *testing.T) { 124 | b := make([]byte, 1024) 125 | rand.Read(b) 126 | if _, err := newMustacheTemplate(iotest.TimeoutReader(bytes.NewBuffer(b))); err == nil { 127 | t.Error("expecting error!") 128 | } 129 | } 130 | 131 | func checkRenderer(tmpl Renderer) error { 132 | w := &bytes.Buffer{} 133 | ctx := map[string]interface{}{"a": 42} 134 | if err := tmpl.Render(w, ctx); err != nil { 135 | return err 136 | } 137 | if w.String() != "-42-" { 138 | return fmt.Errorf("unexpected render result: %s", w.String()) 139 | } 140 | return nil 141 | } 142 | -------------------------------------------------------------------------------- /engine/response.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "time" 7 | 8 | "github.com/gin-gonic/gin" 9 | newrelic "github.com/newrelic/go-agent" 10 | nrgin "github.com/newrelic/go-agent/_integrations/nrgin/v1" 11 | ) 12 | 13 | // ResponseContext is the struct ready to rendered and returned to the Handler 14 | type ResponseContext struct { 15 | // Data cotains the backend data if the response was decoded as a struct 16 | Data map[string]interface{} 17 | // Array cotains the backend data if the response was decoded as an array 18 | Array []map[string]interface{} 19 | // Extra contains the extra data injected from the config 20 | Extra map[string]interface{} 21 | // Params stores the params of the request 22 | Params map[string]string 23 | // Helper is a struct containing a few basic template helpers 24 | Helper interface{} `json:"-"` 25 | // Context is a reference to the gin context for the request 26 | Context *gin.Context `json:"-"` 27 | } 28 | 29 | // String implements the Stringer interface 30 | func (r *ResponseContext) String() string { 31 | d, err := json.MarshalIndent(r, "", "\t") 32 | if err != nil { 33 | log.Println(err.Error()) 34 | return "" 35 | } 36 | return string(d) 37 | } 38 | 39 | // ResponseGenerator is a function that, given a gin request, returns a response struc and an error 40 | type ResponseGenerator func(*gin.Context) (ResponseContext, error) 41 | 42 | // NoopResponse is a ResponseGenerator that always returns an empty response and the 43 | // ErrNoResponseGeneratorDefined error 44 | func NoopResponse(_ *gin.Context) (ResponseContext, error) { 45 | return ResponseContext{}, ErrNoResponseGeneratorDefined 46 | } 47 | 48 | // StaticResponseGenerator is a ResponseGenerator that creates a response just by adding the 49 | // default response values to the ResponseContext and a zero value BackendData 50 | type StaticResponseGenerator struct { 51 | Page Page 52 | } 53 | 54 | // ResponseGenerator implements the ResponseGenerator interface 55 | func (s *StaticResponseGenerator) ResponseGenerator(c *gin.Context) (ResponseContext, error) { 56 | if newrelicApp != nil { 57 | defer newrelic.StartSegment(nrgin.Transaction(c), "Request manipulation").End() 58 | } 59 | params := map[string]string{} 60 | for _, v := range c.Params { 61 | params[v.Key] = v.Value 62 | } 63 | target := ResponseContext{ 64 | Extra: s.Page.Extra, 65 | Context: c, 66 | Params: params, 67 | Helper: &tplHelper{}, 68 | } 69 | return target, nil 70 | } 71 | 72 | // DynamicResponseGenerator is a ResponseGenerator that creates a response by adding the decoded data 73 | // returned by the Backend wo the default response values. Depending on the selected decoder, 74 | // the generated responses may have the backend data stored at the `Obj` or at the `Arr` part 75 | type DynamicResponseGenerator struct { 76 | Page Page 77 | Backend Backend 78 | Decoder Decoder 79 | } 80 | 81 | // ResponseGenerator implements the ResponseGenerator interface 82 | func (drg *DynamicResponseGenerator) ResponseGenerator(c *gin.Context) (ResponseContext, error) { 83 | var segment newrelic.Segment 84 | if newrelicApp != nil { 85 | segment = newrelic.StartSegment(nrgin.Transaction(c), "Request manipulation") 86 | } 87 | 88 | params := map[string]string{} 89 | for _, v := range c.Params { 90 | params[v.Key] = v.Value 91 | } 92 | headers := map[string]string{} 93 | h := c.Request.Header.Get(drg.Page.Header) 94 | if h != "" { 95 | headers[drg.Page.Header] = h 96 | } 97 | result := ResponseContext{ 98 | Extra: drg.Page.Extra, 99 | Context: c, 100 | Params: params, 101 | Helper: &tplHelper{}, 102 | } 103 | segment.End() 104 | 105 | resp, err := drg.Backend(params, headers, c) 106 | if err != nil { 107 | return result, err 108 | } 109 | 110 | if newrelicApp != nil { 111 | segment = newrelic.StartSegment(nrgin.Transaction(c), "Decoder") 112 | } 113 | err = drg.Decoder(resp.Body, &result) 114 | resp.Body.Close() 115 | segment.End() 116 | 117 | return result, err 118 | } 119 | 120 | type tplHelper struct { 121 | } 122 | 123 | func (tplHelper) Now() string { 124 | return time.Now().String() 125 | } 126 | -------------------------------------------------------------------------------- /engine/config_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | func TestParseConfigFromFile_noFile(t *testing.T) { 11 | c, err := ParseConfigFromFile("unknown") 12 | if err == nil { 13 | t.Error("error expected") 14 | } 15 | if len(c.Templates) != 0 { 16 | t.Error("unexpected number of templates:", c.Templates) 17 | } 18 | if len(c.Pages) != 0 { 19 | t.Error("unexpected number of pages:", c.Pages) 20 | } 21 | } 22 | 23 | func TestParseConfigFromFile_wrongConfig(t *testing.T) { 24 | f, err := ioutil.TempFile(".", "wrong_config") 25 | if err != nil { 26 | t.Error(err) 27 | return 28 | } 29 | if _, err = f.WriteString("{"); err != nil { 30 | t.Error(err) 31 | return 32 | } 33 | f.Close() 34 | 35 | c, err := ParseConfigFromFile(f.Name()) 36 | if err == nil { 37 | t.Error("error expected") 38 | } 39 | if len(c.Templates) != 0 { 40 | t.Error("unexpected number of templates:", c.Templates) 41 | } 42 | if len(c.Pages) != 0 { 43 | t.Error("unexpected number of pages:", c.Pages) 44 | } 45 | os.Remove(f.Name()) 46 | } 47 | 48 | func TestParseConfig_wrongConfig(t *testing.T) { 49 | for i, subject := range []string{ 50 | `{ 51 | "templates":{ 52 | }`, 53 | } { 54 | c, err := ParseConfig(bytes.NewBufferString(subject)) 55 | if err == nil { 56 | t.Error("error expected in", i) 57 | } 58 | if len(c.Templates) != 0 { 59 | t.Error("unexpected number of templates:", c.Templates) 60 | } 61 | if len(c.Pages) != 0 { 62 | t.Error("unexpected number of pages:", c.Pages) 63 | } 64 | } 65 | } 66 | 67 | func TestParseConfig_ok(t *testing.T) { 68 | configContent := `{ 69 | "templates":{ 70 | "template01": "path01", 71 | "template02": "path02", 72 | "template03": "path03" 73 | }, 74 | "pages":[ 75 | { 76 | "name": "page01", 77 | "URLPattern": "/page-01", 78 | "BackendURLPattern": "https://jsonplaceholder.typicode.com/users/1", 79 | "Template": "template01", 80 | "CacheTTL": "3600s" 81 | }, 82 | { 83 | "name": "page02", 84 | "URLPattern": "/page-02", 85 | "BackendURLPattern": "https://jsonplaceholder.typicode.com/users/2", 86 | "Template": "template02", 87 | "CacheTTL": "3600s" 88 | } 89 | ] 90 | }` 91 | c, err := ParseConfig(bytes.NewBufferString(configContent)) 92 | if err != nil { 93 | t.Error(err) 94 | } 95 | if len(c.Templates) != 3 { 96 | t.Error("unexpected number of templates:", c.Templates) 97 | } 98 | if len(c.Pages) != 2 { 99 | t.Error("unexpected number of pages:", c.Pages) 100 | } 101 | } 102 | 103 | func TestParseConfig_extra(t *testing.T) { 104 | configContent := `{ 105 | "templates":{ 106 | "template01": "path01", 107 | "template02": "path02", 108 | "template03": "path03" 109 | }, 110 | "pages":[ 111 | { 112 | "name": "page01", 113 | "URLPattern": "/page-01", 114 | "BackendURLPattern": "https://jsonplaceholder.typicode.com/users/1", 115 | "Template": "template01", 116 | "CacheTTL": "3600s" 117 | }, 118 | { 119 | "name": "page02", 120 | "URLPattern": "/page-02", 121 | "BackendURLPattern": "https://jsonplaceholder.typicode.com/users/2", 122 | "Template": "template02", 123 | "CacheTTL": "3600s", 124 | "extra": { 125 | "b": true 126 | } 127 | } 128 | ], 129 | "extra": { 130 | "a": { 131 | "a1": 42 132 | } 133 | } 134 | }` 135 | c, err := ParseConfig(bytes.NewBufferString(configContent)) 136 | if err != nil { 137 | t.Error(err) 138 | } 139 | if len(c.Templates) != 3 { 140 | t.Error("unexpected number of templates:", c.Templates) 141 | } 142 | if len(c.Pages) != 2 { 143 | t.Error("unexpected number of pages:", c.Pages) 144 | } 145 | for i, p := range c.Pages { 146 | if tmp, ok := p.Extra["a"].(map[string]interface{}); !ok { 147 | t.Errorf("the page #%d has a wrong extra. have: %v", i, p.Extra) 148 | } else if v, ok := tmp["a1"].(float64); !ok || v != 42 { 149 | t.Errorf("the page #%d has a wrong extra['a']. have: %v (%f)", i, tmp, v) 150 | } 151 | if p.Name == "page02" { 152 | if tmp, ok := p.Extra["b"].(bool); !ok || !tmp { 153 | t.Errorf("the page #%d has a wrong extra. have: %v", i, p.Extra) 154 | } 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /engine/templates.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | var ( 4 | default404Tmpl = ` 5 | 6 | 7 | 8 | 9 | Page not found 10 | 11 | 12 |

Page not found!

13 |

The page you are looking for is not hosted in this site

14 |

You might want to customize this file by editing static/404

15 | ` 16 | 17 | default500Tmpl = ` 18 | 19 | 20 | 21 | 22 | Bummer! 23 | 24 | 25 |

API response went wild!

26 |

The response from the API was weird an unable to process it.

27 |

You might want to customize this file by editing static/500

28 | ` 29 | 30 | debuggerTmpl = `
31 |

API2HTML Debugger

32 |

Page generated at {{ Helper.Now }}

33 |

Response context

34 |
35 |
{{ String }}
36 |
37 | 38 |

Request context parameters (Context.params)

39 |
40 | {{ #Context.params }} 41 |
{{ . }}
42 | {{ /Context.params }} 43 | {{ ^Context.params }} 44 |

No context parameters set.

45 | {{ /Context.params }} 46 |
47 |

Request context keys (Context.keys)

48 |
49 | 50 | {{ #Context.keys }} 51 |
{{ . }}
52 | {{ /Context.keys }} 53 | {{ ^Context.keys }} 54 |

No context keys set.

55 | {{ /Context.keys }} 56 | 57 |
58 |

Request parameters (Params)

59 |
60 | {{ #Params }} 61 |
{{ . }}
62 | {{ /Params }} 63 | {{ ^Params }} 64 |

This page didn't set any parameters in the URL.

65 | {{ /Params }} 66 |
67 |

Extra data from config (Extra)

68 |
69 | {{ #Extra }} 70 |
{{ . }}
71 | {{ /Extra }} 72 | {{ ^Extra }} 73 |

The configuration file does not add any extra data.

74 | {{ /Extra }} 75 |
76 |

Backend response

77 |
78 |

Response when object (Data)

79 | {{ #Data }} 80 |
{{ . }}
81 | {{ /Data }} 82 | {{ ^Data }} 83 |

The backend response did not return an object.

84 | {{ /Data }} 85 | 86 |

Response when array (Array)

87 | {{ #Array }} 88 |
{{ . }}
89 | {{ /Array }} 90 | {{ ^Array }} 91 |

The backend response did not return an array or configuration does not set isArray.

92 | {{ /Array }} 93 |
94 |
95 | ` 121 | ) 122 | -------------------------------------------------------------------------------- /engine/factory.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/gin-contrib/static" 10 | "github.com/gin-gonic/gin" 11 | newrelic "github.com/newrelic/go-agent" 12 | nrgin "github.com/newrelic/go-agent/_integrations/nrgin/v1" 13 | ) 14 | 15 | // DefaultFactory is an Factory ready to be used 16 | var DefaultFactory = Factory{ 17 | TemplateStoreFactory: NewTemplateStore, 18 | Parser: ParseConfigFromFile, 19 | MustachePageFactory: NewMustachePageFactory, 20 | StaticHandlerFactory: NewStaticHandler, 21 | ErrorHandlerFactory: NewErrorHandler, 22 | } 23 | 24 | // Factory is a struct able to build api2html engines 25 | type Factory struct { 26 | TemplateStoreFactory func() *TemplateStore 27 | Parser func(string) (Config, error) 28 | MustachePageFactory func(*gin.Engine, *TemplateStore) MustachePageFactory 29 | StaticHandlerFactory func(string) (StaticHandler, error) 30 | ErrorHandlerFactory func(string, int) (ErrorHandler, error) 31 | } 32 | 33 | // New creates a gin engine with the received config and the injected factories 34 | func (ef Factory) New(cfgPath string, devel bool) (*gin.Engine, error) { 35 | cfg, err := ef.Parser(cfgPath) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | if cfg.NewRelic != nil && cfg.NewRelic.License != "" { 41 | nrCfg := newrelic.NewConfig(cfg.NewRelic.AppName, cfg.NewRelic.License) 42 | if devel { 43 | nrCfg.Logger = newrelic.NewDebugLogger(os.Stdout) 44 | } 45 | nrapp, err := newrelic.NewApplication(nrCfg) 46 | if err != nil { 47 | return nil, err 48 | } 49 | newrelicApp = &nrapp 50 | } 51 | 52 | templateStore := ef.TemplateStoreFactory() 53 | e := ef.newGinEngine(cfg, devel) 54 | pf := ef.MustachePageFactory(e, templateStore) 55 | pf.Build(cfg) 56 | 57 | if h, err := ef.StaticHandlerFactory("./static/404"); err == nil { 58 | e.NoRoute(h.HandlerFunc()) 59 | } else { 60 | log.Println("using the default 404 template") 61 | e.NoRoute(Default404StaticHandler.HandlerFunc()) 62 | } 63 | 64 | if devel { 65 | e.PUT("/template/:templateName", func(c *gin.Context) { 66 | file, err := c.FormFile("file") 67 | if err != nil { 68 | c.AbortWithError(http.StatusInternalServerError, err) 69 | return 70 | } 71 | 72 | f, err := file.Open() 73 | if err != nil { 74 | c.AbortWithError(http.StatusInternalServerError, err) 75 | return 76 | } 77 | 78 | defer f.Close() 79 | 80 | tmp, err := NewMustacheRenderer(f) 81 | if err != nil { 82 | c.AbortWithError(http.StatusInternalServerError, err) 83 | return 84 | } 85 | 86 | templateName := c.Param("templateName") 87 | if err := templateStore.Set(templateName, tmp); err != nil { 88 | c.AbortWithError(http.StatusInternalServerError, err) 89 | return 90 | } 91 | 92 | c.String(http.StatusOK, fmt.Sprintf("'%s' uploaded and stored as [%s]!", templateName, file.Filename)) 93 | }) 94 | } 95 | return e, nil 96 | } 97 | 98 | func (ef Factory) newGinEngine(cfg Config, devel bool) *gin.Engine { 99 | if !devel { 100 | gin.SetMode(gin.ReleaseMode) 101 | } 102 | e := gin.Default() 103 | e.RedirectTrailingSlash = true 104 | e.RedirectFixedPath = true 105 | 106 | if newrelicApp != nil { 107 | e.Use(nrgin.Middleware(*newrelicApp)) 108 | } 109 | ef.setStatics(e, cfg) 110 | 111 | return e 112 | } 113 | 114 | func (ef Factory) setStatics(e *gin.Engine, cfg Config) { 115 | if cfg.PublicFolder != nil { 116 | e.Use(static.Serve(cfg.PublicFolder.Prefix, static.LocalFile(cfg.PublicFolder.Path, false))) 117 | } 118 | 119 | if cfg.Robots { 120 | log.Println("registering the robots file") 121 | e.StaticFile("/robots.txt", "./static/robots.txt") 122 | } 123 | 124 | if cfg.Sitemap { 125 | log.Println("registering the sitemap file") 126 | e.StaticFile("/sitemap.xml", "./static/sitemap.xml") 127 | } 128 | 129 | for _, fileName := range cfg.StaticTXTContent { 130 | log.Println("registering the static", fileName) 131 | e.StaticFile(fmt.Sprintf("/%s", fileName), fmt.Sprintf("./static/%s", fileName)) 132 | } 133 | 134 | if h, err := ef.ErrorHandlerFactory("./static/500", http.StatusInternalServerError); err == nil { 135 | e.Use(h.HandlerFunc()) 136 | } else { 137 | log.Println("using the default 500 template") 138 | e.Use(Default500StaticHandler.HandlerFunc()) 139 | } 140 | 141 | } 142 | -------------------------------------------------------------------------------- /engine/engine.go: -------------------------------------------------------------------------------- 1 | // Package engine contains all the required for building and running an API2HTML server 2 | // 3 | // func Run(cfgPath string, devel bool) error { 4 | // errNilEngine := fmt.Errorf("serve cmd aborted: nil engine") 5 | // e, err := engine.New(cfgPath, devel) 6 | // if err != nil { 7 | // log.Println("engine creation aborted:", err.Error()) 8 | // return err 9 | // } 10 | // if e == nil { 11 | // log.Println("engine creation aborted:", errNilEngine.Error()) 12 | // return errNilEngine 13 | // } 14 | // 15 | // time.Sleep(time.Second) 16 | // 17 | // return eW.Run(fmt.Sprintf(":%d", port)) 18 | // } 19 | package engine 20 | 21 | import ( 22 | "fmt" 23 | "io" 24 | "net/http" 25 | 26 | "github.com/gin-gonic/gin" 27 | newrelic "github.com/newrelic/go-agent" 28 | ) 29 | 30 | // Config is a struct with all the required definitions for building an API2HTML engine 31 | type Config struct { 32 | Pages []Page `json:"pages"` 33 | StaticTXTContent []string `json:"static_txt_content"` 34 | Robots bool `json:"robots"` 35 | Sitemap bool `json:"sitemap"` 36 | Templates map[string]string `json:"templates"` 37 | Layouts map[string]string `json:"layouts"` 38 | Extra map[string]interface{} `json:"extra"` 39 | PublicFolder *PublicFolder `json:"public_folder"` 40 | NewRelic *NewRelic `json:"newrelic"` 41 | } 42 | 43 | // PublicFolder contains the info regarding the static contents to be served 44 | type PublicFolder struct { 45 | Path string `json:"path_to_folder"` 46 | Prefix string `json:"url_prefix"` 47 | } 48 | 49 | // NewRelic contains the info regarding the app name and the newrelic license key 50 | type NewRelic struct { 51 | AppName string `json:"app_name"` 52 | License string `json:"license"` 53 | } 54 | 55 | // Page defines the behaviour of the engine for a given URL pattern 56 | type Page struct { 57 | Name string 58 | URLPattern string 59 | BackendURLPattern string 60 | Template string 61 | Layout string 62 | CacheTTL string 63 | Header string 64 | IsArray bool 65 | Extra map[string]interface{} 66 | } 67 | 68 | // New creates a gin engine with the default Factory 69 | func New(cfgPath string, devel bool) (*gin.Engine, error) { 70 | return DefaultFactory.New(cfgPath, devel) 71 | } 72 | 73 | // Backend defines the signature of the function that creates a response for a request 74 | // to a given backend 75 | type Backend func(params map[string]string, headers map[string]string, c *gin.Context) (*http.Response, error) 76 | 77 | // Renderer defines the interface for the template renderers 78 | type Renderer interface { 79 | Render(io.Writer, interface{}) error 80 | } 81 | 82 | // RendererFunc is a function implementing the Renderer interface 83 | type RendererFunc func(io.Writer, interface{}) error 84 | 85 | // Render implements the Renderer interface 86 | func (rf RendererFunc) Render(w io.Writer, v interface{}) error { return rf(w, v) } 87 | 88 | // Subscription is a struct to be used to be notified after a change in the watched renderer 89 | type Subscription struct { 90 | // Name is the name to watch 91 | Name string 92 | // In is the channel where the new renderer should be sent after a change 93 | In chan Renderer 94 | } 95 | 96 | // ErrorRenderer is a renderer that always returns the injected error 97 | type ErrorRenderer struct { 98 | Error error 99 | } 100 | 101 | // Render implements the Renderer interface by returning the injected error 102 | func (r ErrorRenderer) Render(_ io.Writer, _ interface{}) error { return r.Error } 103 | 104 | // ErrNoResponseGeneratorDefined is the error returned when no ResponseGenerator has been defined 105 | var ErrNoResponseGeneratorDefined = fmt.Errorf("no response generator defined") 106 | 107 | // ErrNoBackendDefined is the error returned when no Backend has been defined 108 | var ErrNoBackendDefined = fmt.Errorf("no backend defined") 109 | 110 | // ErrNoRendererDefined is the error returned when no Renderer has been defined 111 | var ErrNoRendererDefined = fmt.Errorf("no rendered defined") 112 | 113 | // EmptyRenderer is the Renderer to be use if no other is defined 114 | var EmptyRenderer = ErrorRenderer{ErrNoRendererDefined} 115 | 116 | var newrelicApp *newrelic.Application 117 | -------------------------------------------------------------------------------- /engine/engine_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "testing" 10 | "time" 11 | 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | func TestNew(t *testing.T) { 16 | if err := ioutil.WriteFile("test_tmpl", []byte("hi, {{Extra.name}}!"), 0644); err != nil { 17 | t.Errorf("unexpected error: %s", err.Error()) 18 | } 19 | defer os.Remove("test_tmpl") 20 | if err := ioutil.WriteFile("test_lyt", []byte("-{{{content}}}-"), 0644); err != nil { 21 | t.Errorf("unexpected error: %s", err.Error()) 22 | } 23 | defer os.Remove("test_lyt") 24 | if err := os.Mkdir("static", 0777); err != nil { 25 | t.Errorf("unexpected error: %s", err.Error()) 26 | } 27 | defer os.RemoveAll("static") 28 | if err := ioutil.WriteFile("static/s.txt", []byte("12345"), 0644); err != nil { 29 | t.Errorf("unexpected error: %s", err.Error()) 30 | } 31 | if err := ioutil.WriteFile("static/404", []byte("404"), 0644); err != nil { 32 | t.Errorf("unexpected error: %s", err.Error()) 33 | } 34 | if err := ioutil.WriteFile("static/500", []byte("500"), 0644); err != nil { 35 | t.Errorf("unexpected error: %s", err.Error()) 36 | } 37 | if err := ioutil.WriteFile("static/robots.txt", []byte("robots.txt"), 0644); err != nil { 38 | t.Errorf("unexpected error: %s", err.Error()) 39 | } 40 | if err := ioutil.WriteFile("static/sitemap.xml", []byte("sitemap.xml"), 0644); err != nil { 41 | t.Errorf("unexpected error: %s", err.Error()) 42 | } 43 | if err := os.Mkdir("public", 0777); err != nil { 44 | t.Errorf("unexpected error: %s", err.Error()) 45 | } 46 | defer os.RemoveAll("public") 47 | if err := ioutil.WriteFile("public/public.js", []byte("public"), 0644); err != nil { 48 | t.Errorf("unexpected error: %s", err.Error()) 49 | } 50 | 51 | cfg := Config{ 52 | Pages: []Page{ 53 | { 54 | URLPattern: "/ok/1", 55 | Layout: "b", 56 | Template: "a", 57 | Extra: map[string]interface{}{ 58 | "name": "stranger", 59 | }, 60 | }, 61 | { 62 | URLPattern: "/ok/2", 63 | Template: "a", 64 | Extra: map[string]interface{}{ 65 | "name": "stranger", 66 | }, 67 | }, 68 | { 69 | URLPattern: "/ko/1", 70 | }, 71 | { 72 | URLPattern: "/ko/2", 73 | Layout: "unknown", 74 | Template: "a", 75 | Extra: map[string]interface{}{ 76 | "name": "stranger", 77 | }, 78 | }, 79 | }, 80 | StaticTXTContent: []string{"s.txt"}, 81 | Templates: map[string]string{"a": "test_tmpl"}, 82 | Layouts: map[string]string{"b": "test_lyt"}, 83 | Robots: true, 84 | Sitemap: true, 85 | PublicFolder: &PublicFolder{ 86 | Path: "./public", 87 | Prefix: "/js", 88 | }, 89 | } 90 | data, err := json.Marshal(cfg) 91 | if err != nil { 92 | t.Errorf("unexpected error: %s", err.Error()) 93 | return 94 | } 95 | if err = ioutil.WriteFile("public/config.json", data, 0644); err != nil { 96 | t.Errorf("unexpected error: %s", err.Error()) 97 | return 98 | } 99 | 100 | e, err := New("public/config.json", false) 101 | if err != nil { 102 | t.Errorf("unexpected error: %s", err.Error()) 103 | return 104 | } 105 | 106 | time.Sleep(200 * time.Millisecond) 107 | 108 | assertResponse(t, e, "/ok/1", http.StatusOK, "-hi, stranger!-") 109 | assertResponse(t, e, "/ok/2", http.StatusOK, "hi, stranger!") 110 | assertResponse(t, e, "/ko/1", http.StatusInternalServerError, "500") 111 | assertResponse(t, e, "/ko/2", http.StatusInternalServerError, "500") 112 | assertResponse(t, e, "/b", http.StatusNotFound, "404") 113 | assertResponse(t, e, "/robots.txt", http.StatusOK, "robots.txt") 114 | assertResponse(t, e, "/sitemap.xml", http.StatusOK, "sitemap.xml") 115 | assertResponse(t, e, "/js/public.js", http.StatusOK, "public") 116 | assertResponse(t, e, "/s.txt", http.StatusOK, "12345") 117 | } 118 | 119 | func assertResponse(t *testing.T, e *gin.Engine, url string, status int, body string) { 120 | w := httptest.NewRecorder() 121 | req, err := http.NewRequest("GET", url, nil) 122 | if err != nil { 123 | t.Errorf("[%s] unexpected error: %s", url, err.Error()) 124 | return 125 | } 126 | e.ServeHTTP(w, req) 127 | if statusCode := w.Result().StatusCode; statusCode != status { 128 | t.Errorf("[%s] unexpected status code: %d (%v)", url, statusCode, w.Result()) 129 | } 130 | 131 | data, err := ioutil.ReadAll(w.Result().Body) 132 | if err != nil { 133 | t.Errorf("[%s] unexpected error: %s (%v)", url, err.Error(), w.Result()) 134 | return 135 | } 136 | w.Result().Body.Close() 137 | 138 | if string(data) != body { 139 | t.Errorf("[%s] unexpected body: %s (%v)", url, string(data), w.Result()) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /generator/collector.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "sort" 9 | "strings" 10 | 11 | "github.com/Unknwon/goconfig" 12 | ) 13 | 14 | // Collector defines the interface for collecting config and translation data 15 | type Collector interface { 16 | Collect(string) (Data, error) 17 | AvailableISOs() []string 18 | } 19 | 20 | // ErrNoConfig is the error to be returned if there are no config files 21 | var ErrNoConfig = fmt.Errorf("no config files") 22 | 23 | // Data contains all the collected data 24 | type Data struct { 25 | I18N map[string]Map 26 | Config map[string]Map 27 | } 28 | 29 | func (d Data) String() string { 30 | b, err := json.Marshal(d) 31 | if err != nil { 32 | log.Printf(err.Error()) 33 | return "" 34 | } 35 | return string(b) 36 | } 37 | 38 | type Map map[string]string 39 | 40 | func (d Map) String() string { 41 | b, err := json.Marshal(d) 42 | if err != nil { 43 | log.Printf(err.Error()) 44 | return "" 45 | } 46 | return string(b) 47 | } 48 | 49 | func NewCollector(configFolder, i18nFolder string) Collector { 50 | return SimpleCollector{ConfigFolder: configFolder, I18NFolder: i18nFolder} 51 | } 52 | 53 | type SimpleCollector struct { 54 | ConfigFolder string 55 | I18NFolder string 56 | } 57 | 58 | func (c SimpleCollector) Collect(iso string) (Data, error) { 59 | data := Data{} 60 | translations, err := c.getTranslations(iso) 61 | if err != nil { 62 | return data, err 63 | } 64 | data.I18N = translations 65 | 66 | configs, err := c.getConfigurations(iso) 67 | if err != nil { 68 | return data, err 69 | } 70 | data.Config = configs 71 | 72 | return data, nil 73 | } 74 | 75 | func (c SimpleCollector) AvailableISOs() []string { 76 | tmp := map[string]struct{}{} 77 | for _, iso := range c.availableConfigISOs() { 78 | tmp[iso] = struct{}{} 79 | } 80 | for _, iso := range c.availableTranslationISOs() { 81 | tmp[iso] = struct{}{} 82 | } 83 | isos := []string{} 84 | for iso := range tmp { 85 | isos = append(isos, iso) 86 | } 87 | sort.Strings(isos) 88 | return isos 89 | } 90 | 91 | func (c SimpleCollector) availableTranslationISOs() []string { 92 | isos := []string{} 93 | files, err := ioutil.ReadDir(c.I18NFolder) 94 | if err != nil { 95 | log.Printf("looking for available translation ISO codes: %s", err.Error()) 96 | return isos 97 | } 98 | 99 | for _, f := range files { 100 | if !f.IsDir() && strings.Contains(f.Name(), ".ini") { 101 | isos = append(isos, strings.Trim(f.Name(), ".ini")) 102 | } 103 | } 104 | return isos 105 | } 106 | 107 | func (c SimpleCollector) availableConfigISOs() []string { 108 | isos := []string{} 109 | files, err := ioutil.ReadDir(c.ConfigFolder) 110 | if err != nil { 111 | log.Printf("looking for available config ISO codes: %s", err.Error()) 112 | return isos 113 | } 114 | 115 | for _, f := range files { 116 | if f.IsDir() && f.Name() != "global" { 117 | isos = append(isos, f.Name()) 118 | } 119 | } 120 | return isos 121 | } 122 | 123 | func (c SimpleCollector) getConfigurations(iso string) (map[string]Map, error) { 124 | configFiles := []string{} 125 | 126 | for _, p := range []string{ 127 | fmt.Sprintf("%s/global", c.ConfigFolder), 128 | fmt.Sprintf("%s/%s", c.ConfigFolder, iso), 129 | } { 130 | files, err := ioutil.ReadDir(p) 131 | if err != nil { 132 | log.Println(err.Error()) 133 | continue 134 | } 135 | 136 | for _, f := range files { 137 | configFiles = append(configFiles, fmt.Sprintf("%s/%s", p, f.Name())) 138 | } 139 | } 140 | 141 | result := map[string]Map{} 142 | 143 | extra := []string{} 144 | switch len(configFiles) { 145 | case 0: 146 | return result, ErrNoConfig 147 | case 1: 148 | default: 149 | extra = configFiles[1:] 150 | } 151 | configs, err := goconfig.LoadConfigFile(configFiles[0], extra...) 152 | if err != nil { 153 | return result, err 154 | } 155 | 156 | for _, section := range configs.GetSectionList() { 157 | configSection, err := configs.GetSection(section) 158 | if err != nil { 159 | return result, err 160 | } 161 | log.Println("loaded config section", section, configSection) 162 | result[section] = configSection 163 | } 164 | 165 | return result, nil 166 | } 167 | 168 | func (c SimpleCollector) getTranslations(iso string) (map[string]Map, error) { 169 | result := map[string]Map{} 170 | translations, err := goconfig.LoadConfigFile(fmt.Sprintf("%s/%s.ini", c.I18NFolder, iso)) 171 | if err != nil { 172 | return result, err 173 | } 174 | 175 | for _, section := range translations.GetSectionList() { 176 | configSection, err := translations.GetSection(section) 177 | if err != nil { 178 | return result, err 179 | } 180 | log.Println("loaded translation section", section, "with", len(configSection), "translations") 181 | result[section] = configSection 182 | } 183 | 184 | return result, nil 185 | } 186 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![api2html](https://raw.githubusercontent.com/devopsfaith/api2html.com/master/img/api2html-on-white.png) 3 | 4 | [![Build Status](https://travis-ci.org/devopsfaith/api2html.svg?branch=master)](https://travis-ci.org/devopsfaith/api2html) [![Go Report Card](https://goreportcard.com/badge/github.com/devopsfaith/api2html)](https://goreportcard.com/report/github.com/devopsfaith/api2html) [![Coverage Status](https://coveralls.io/repos/github/devopsfaith/api2html/badge.svg?branch=master)](https://coveralls.io/github/devopsfaith/api2html?branch=master) [![GoDoc](https://godoc.org/github.com/devopsfaith/api2html?status.svg)](https://godoc.org/github.com/devopsfaith/api2html) 5 | 6 | ### On the fly HTML generator from API data 7 | 8 | API2HTML is a web server that renders [Mustache](http://mustache.github.io/) templates and injects them your API data. This allows you to build websites by just declaring the API sources and writing the template view. 9 | 10 | ## How does it work? 11 | To create pages that feed from a backend you just need to add in the configuration file the URL patterns the server will listen to. Let's imagine we want to offer URLs like `/products/13-inches-laptops` where the second part is a variable that will be sent to the API: 12 | 13 | ... 14 | "pages":[ 15 | { 16 | "name": "products", 17 | "URLPattern": "/products/:category", 18 | "BackendURLPattern": "http://api.company.com/products/:category", 19 | "Template": "products_list", 20 | "CacheTTL": "3600s", 21 | "extra": { 22 | "promo":"Black Friday" 23 | } 24 | }, 25 | ... 26 | 27 | The `Template` setting will look for the file `tmpl/products_list.mustache` and the response of the BackendURLPattern call will be injected in the variable `data`. An example of how you could use it: 28 | 29 |

Products for sale

30 |

Take advantage of the {{extra.promo}}!

31 | 32 | {{#data}} 33 | 34 | 35 | 36 | 37 | {{/data}} 38 | 39 | {{^data}} 40 | 41 | 42 | 43 | {{/data}} 44 |
{{name}}{{price}}
There are no products in this category
45 | 46 | You probably guessed it already, but in this scenario the backend would be returning a response like this: 47 | 48 | // http://api.company.com/products/13-inches-laptops 49 | { 50 | [ 51 | { "name": "13-inch MacBook Air", "price": "$999.00" }, 52 | { "name": "Lenovo ThinkPad 13", "price": "$752.00" }, 53 | { "name": "Dell XPS13", "price": "$925.00" } 54 | ] 55 | } 56 | 57 | 58 | ## Install 59 | 60 | When you install `api2html` for the first time you need to download the dependencies, automatically managed by `dep`. Install it with: 61 | 62 | $ make prepare 63 | 64 | Once all dependencies are installed just run: 65 | 66 | $ make 67 | 68 | ## Run 69 | Once you have successfully compiled API2HTML in your platform the binary `api2html` will exist in the folder. Execute it as follows: 70 | 71 | $ ./api2html -h 72 | Template Render As A Service 73 | 74 | Usage: 75 | api2html [command] 76 | 77 | Available Commands: 78 | generate Generate the final api2html templates. 79 | serve Run the api2html server. 80 | 81 | Use "api2html [command] --help" for more information about a command. 82 | 83 | ### Run the engine 84 | 85 | $ ./api2html run -h 86 | Run the api2html server. 87 | 88 | Usage: 89 | api2html serve [flags] 90 | 91 | Aliases: 92 | serve, run, server, start 93 | 94 | Examples: 95 | api2html serve -d -c config.json -p 8080 96 | 97 | Flags: 98 | -c, --config string Path to the configuration filename (default "config.json") 99 | -d, --devel Enable the devel 100 | -p, --port int Listen port (default 8080) 101 | 102 | ### Generator 103 | The generator allows you to create multiple mustache files using templating. That's right create templates with templates! 104 | 105 | $ ./api2html generate -h 106 | Generate the final api2html templates. 107 | 108 | Usage: 109 | api2html generate [flags] 110 | 111 | Aliases: 112 | generate, create, new 113 | 114 | 115 | Examples: 116 | api2html generate -d -c config.json 117 | 118 | Flags: 119 | -i, --iso string (comma-separated) iso code of the site to create (default "*") 120 | -p, --path string Base path for the generation (default ".") 121 | -r, --reg string regex filtering the sources to move to the output folder (default "ignore") 122 | 123 | ### Hot template reload 124 | 125 | $ curl -X PUT -F "file=@/path/to/tmpl.mustache" -H "Content-Type: multipart/form-data" \ 126 | http://localhost:8080/template/ 127 | 128 | ## Building and running with Docker 129 | To build the project with Docker: 130 | 131 | $ make docker 132 | 133 | And run it as follows: 134 | 135 | $ docker run -it --rm -p8080:8080 -v $PWD/config.json:/etc/api2html/config.json api2html -d -c /etc/api2html/config.json 136 | -------------------------------------------------------------------------------- /engine/factory_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "mime/multipart" 9 | "net/http" 10 | "net/http/httptest" 11 | "os" 12 | "testing" 13 | "time" 14 | 15 | "github.com/gin-gonic/gin" 16 | ) 17 | 18 | func TestFactory_New_koConfigParser(t *testing.T) { 19 | expectedErr := fmt.Errorf("boooom") 20 | ef := Factory{ 21 | Parser: func(path string) (Config, error) { 22 | if path != "something" { 23 | t.Errorf("unexpected path: %s", path) 24 | } 25 | return Config{}, expectedErr 26 | }, 27 | } 28 | if _, err := ef.New("something", true); err == nil { 29 | t.Error("expecting error") 30 | } else if err != expectedErr { 31 | t.Errorf("unexpected error: %s", err.Error()) 32 | } 33 | } 34 | 35 | func TestFactory_New_ok(t *testing.T) { 36 | if err := ioutil.WriteFile("test_tmpl", []byte("hi, {{Extra.name}}!"), 0644); err != nil { 37 | t.Errorf("unexpected error: %s", err.Error()) 38 | } 39 | if err := ioutil.WriteFile("test_lyt", []byte("-{{{content}}}-"), 0644); err != nil { 40 | t.Errorf("unexpected error: %s", err.Error()) 41 | } 42 | defer os.Remove("test_tmpl") 43 | defer os.Remove("test_lyt") 44 | expectedCfg := Config{ 45 | Pages: []Page{ 46 | { 47 | URLPattern: "/a", 48 | Layout: "b", 49 | Template: "a", 50 | Extra: map[string]interface{}{ 51 | "name": "stranger", 52 | }, 53 | }, 54 | }, 55 | Templates: map[string]string{"a": "test_tmpl"}, 56 | Layouts: map[string]string{"b": "test_lyt"}, 57 | } 58 | templateStore := NewTemplateStore() 59 | ef := DefaultFactory 60 | ef.Parser = func(path string) (Config, error) { 61 | if path != "something" { 62 | t.Errorf("unexpected path: %s", path) 63 | } 64 | return expectedCfg, nil 65 | } 66 | ef.TemplateStoreFactory = func() *TemplateStore { return templateStore } 67 | ef.MustachePageFactory = func(e *gin.Engine, ts *TemplateStore) MustachePageFactory { 68 | if ts != templateStore { 69 | t.Errorf("unexpected template store: %v", ts) 70 | } 71 | return NewMustachePageFactory(e, ts) 72 | } 73 | 74 | e, err := ef.New("something", true) 75 | if err != nil { 76 | t.Errorf("unexpected error: %s", err.Error()) 77 | return 78 | } 79 | 80 | time.Sleep(200 * time.Millisecond) 81 | 82 | assertResponse(t, e, "/a", http.StatusOK, "-hi, stranger!-") 83 | assertResponse(t, e, "/b", http.StatusNotFound, default404Tmpl) 84 | } 85 | 86 | func TestFactory_New_reloadTemplate(t *testing.T) { 87 | if err := ioutil.WriteFile("test_tmpl", []byte("hi, {{Extra.name}}!"), 0644); err != nil { 88 | t.Errorf("unexpected error: %s", err.Error()) 89 | } 90 | defer os.Remove("test_tmpl") 91 | 92 | expectedCfg := Config{ 93 | Pages: []Page{ 94 | { 95 | URLPattern: "/a", 96 | Template: "a", 97 | Extra: map[string]interface{}{ 98 | "name": "stranger", 99 | }, 100 | }, 101 | }, 102 | Templates: map[string]string{"a": "test_tmpl"}, 103 | } 104 | templateStore := NewTemplateStore() 105 | ef := DefaultFactory 106 | ef.Parser = func(path string) (Config, error) { 107 | if path != "something" { 108 | t.Errorf("unexpected path: %s", path) 109 | } 110 | return expectedCfg, nil 111 | } 112 | ef.TemplateStoreFactory = func() *TemplateStore { return templateStore } 113 | ef.MustachePageFactory = func(e *gin.Engine, ts *TemplateStore) MustachePageFactory { 114 | if ts != templateStore { 115 | t.Errorf("unexpected template store: %v", ts) 116 | } 117 | return NewMustachePageFactory(e, ts) 118 | } 119 | 120 | e, err := ef.New("something", true) 121 | if err != nil { 122 | t.Errorf("unexpected error: %s", err.Error()) 123 | return 124 | } 125 | 126 | time.Sleep(200 * time.Millisecond) 127 | 128 | // Non-existent file param 129 | req, _ := http.NewRequest("PUT", "/template/a", nil) 130 | resp := httptest.NewRecorder() 131 | e.ServeHTTP(resp, req) 132 | 133 | // Invalid template 134 | req, err = putTemplateForm("/template/a", "Hi {{ I'm template with errors.") 135 | if err != nil { 136 | t.Errorf("Error creating PUT Form body: %s", err.Error()) 137 | } 138 | resp = httptest.NewRecorder() 139 | e.ServeHTTP(resp, req) 140 | 141 | if statusCode := resp.Result().StatusCode; statusCode != http.StatusInternalServerError { 142 | t.Errorf("[%s] unexpected status code: %d (%v)", "/template/a", statusCode, resp.Result()) 143 | } 144 | 145 | // Hot reload correctly a template 146 | req, err = putTemplateForm("/template/a", "Hi {{Extra.name}}, I'm updated.") 147 | if err != nil { 148 | t.Errorf("Error creating PUT Form body: %s", err.Error()) 149 | } 150 | resp = httptest.NewRecorder() 151 | e.ServeHTTP(resp, req) 152 | time.Sleep(200 * time.Millisecond) 153 | assertResponse(t, e, "/a", http.StatusOK, "Hi stranger, I'm updated.") 154 | 155 | } 156 | 157 | func putTemplateForm(url, tmpl string) (*http.Request, error) { 158 | buff := &bytes.Buffer{} 159 | tmplWriter := multipart.NewWriter(buff) 160 | fileWriter, err := tmplWriter.CreateFormFile("file", "test_tmpl") 161 | if err != nil { 162 | tmplWriter.Close() 163 | return nil, err 164 | } 165 | 166 | _, err = io.WriteString(fileWriter, tmpl) 167 | tmplWriter.Close() 168 | if err != nil { 169 | return nil, err 170 | } 171 | req, err := http.NewRequest("PUT", url, buff) 172 | if err == nil { 173 | req.Header.Set("Content-Type", tmplWriter.FormDataContentType()) 174 | } 175 | return req, err 176 | } 177 | -------------------------------------------------------------------------------- /cmd/generate_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "sync" 9 | "sync/atomic" 10 | "testing" 11 | "time" 12 | 13 | "github.com/devopsfaith/api2html/generator" 14 | ) 15 | 16 | func Test_defaultGeneratorFactory(t *testing.T) { 17 | g := defaultGeneratorFactory(".", "ignore") 18 | switch g.(type) { 19 | case *generator.BasicGenerator: 20 | default: 21 | t.Errorf("unexpected generator type: %T", g) 22 | } 23 | } 24 | 25 | func Test_generatorWrapper_koErroredGenerator(t *testing.T) { 26 | expectedError := fmt.Errorf("expect me") 27 | 28 | subject := generatorWrapper{func(_, _ string) generator.Generator { 29 | return erroredGenerator{expectedError} 30 | }} 31 | 32 | if err := subject.Generate(nil, []string{}); err == nil { 33 | t.Error("expecting error!") 34 | } else if err != expectedError { 35 | t.Errorf("unexpected error! want: %s, got: %s", expectedError.Error(), err.Error()) 36 | } 37 | } 38 | 39 | func Test_generatorWrapper(t *testing.T) { 40 | expectedIso := "iso1" 41 | expectedBasePath := "basepath1" 42 | expectedIgnoreRegex := "ignoreRegex1" 43 | 44 | wrongIso := "no_iso" 45 | wrongBasePath := "no_base_path" 46 | wrongIgnoreRegex := "no_ignore_regex" 47 | 48 | errIso := fmt.Errorf("generating. have: %s, want: %s", wrongIso, expectedIso) 49 | errBasePath := fmt.Errorf("wrong base path. have: %s, want: %s", wrongBasePath, expectedBasePath) 50 | errIgnoreRegex := fmt.Errorf("wrong ignore regex. have: %s, want: %s", wrongIgnoreRegex, expectedIgnoreRegex) 51 | 52 | spy := func(bp, reg string) generator.Generator { 53 | if expectedBasePath != bp { 54 | return erroredGenerator{fmt.Errorf("wrong base path. have: %s, want: %s", bp, expectedBasePath)} 55 | } 56 | if expectedIgnoreRegex != reg { 57 | return erroredGenerator{fmt.Errorf("wrong ignore regex. have: %s, want: %s", reg, expectedIgnoreRegex)} 58 | } 59 | return spyGenerator{expectedIso} 60 | } 61 | 62 | subject := generatorWrapper{spy} 63 | 64 | isos = wrongIso 65 | basePath = wrongBasePath 66 | ignoreRegex = wrongIgnoreRegex 67 | 68 | if err := subject.Generate(nil, []string{}); err == nil { 69 | t.Error("expecting error!") 70 | return 71 | } else if err.Error() != errBasePath.Error() { 72 | t.Errorf("unexpected error! want: %s, got: %s", errBasePath.Error(), err.Error()) 73 | return 74 | } 75 | 76 | basePath = expectedBasePath 77 | 78 | if err := subject.Generate(nil, []string{}); err == nil { 79 | t.Error("expecting error!") 80 | return 81 | } else if err.Error() != errIgnoreRegex.Error() { 82 | t.Errorf("unexpected error! want: %s, got: %s", errIgnoreRegex.Error(), err.Error()) 83 | return 84 | } 85 | 86 | ignoreRegex = expectedIgnoreRegex 87 | 88 | if err := subject.Generate(nil, []string{}); err == nil { 89 | t.Error("expecting error!") 90 | return 91 | } else if err.Error() != errIso.Error() { 92 | t.Errorf("unexpected error! want: %s, got: %s", errIso.Error(), err.Error()) 93 | return 94 | } 95 | 96 | isos = expectedIso 97 | 98 | if err := subject.Generate(nil, []string{}); err != nil { 99 | t.Error("unexpected error:", err.Error()) 100 | } 101 | } 102 | 103 | func Test_generatorWatchWrapper_koErroredGenerator(t *testing.T) { 104 | expectedError := fmt.Errorf("expect me") 105 | 106 | subject := generatorWatchWrapper{generatorWrapper{func(_, _ string) generator.Generator { 107 | return erroredGenerator{expectedError} 108 | }}} 109 | 110 | if err := subject.Watch(nil, []string{}); err == nil { 111 | t.Error("expecting error!") 112 | } else if err != expectedError { 113 | t.Errorf("unexpected error! want: %s, got: %s", expectedError.Error(), err.Error()) 114 | } 115 | } 116 | 117 | func Test_generatorWatchWrapper_koErroredGeneratorAfterChange(t *testing.T) { 118 | name, err := ioutil.TempDir(".", "tmp") 119 | if err != nil { 120 | t.Error(err) 121 | return 122 | } 123 | defer os.RemoveAll(name) 124 | 125 | expectedError := fmt.Errorf("expect me") 126 | var counter uint64 127 | isos = "*" 128 | basePath = name 129 | ignoreRegex = "ignore" 130 | 131 | ctx, cancel := context.WithCancel(context.Background()) 132 | defer cancel() 133 | 134 | subject := generatorWatchWrapper{generatorWrapper{func(basePath, ignoreRegex string) generator.Generator { 135 | select { 136 | case <-ctx.Done(): 137 | return erroredGenerator{expectedError} 138 | default: 139 | } 140 | 141 | if basePath != name { 142 | t.Errorf("unexpected base path. have %s want %s", basePath, name) 143 | } 144 | if ignoreRegex != "ignore" { 145 | t.Errorf("unexpected ignore regex. have %s want %s", ignoreRegex, "ignore") 146 | } 147 | atomic.AddUint64(&counter, 1) 148 | fmt.Println("generation triggered!", counter) 149 | if atomic.LoadUint64(&counter) < 2 { 150 | return spyGenerator{isos} 151 | } 152 | return erroredGenerator{expectedError} 153 | }}} 154 | 155 | wg := &sync.WaitGroup{} 156 | go func() { 157 | wg.Add(1) 158 | if werr := subject.Watch(nil, []string{}); werr == nil { 159 | t.Error("expecting error!") 160 | } else if werr != expectedError { 161 | t.Errorf("unexpected error! want: %s, got: %s", expectedError.Error(), werr.Error()) 162 | } 163 | wg.Done() 164 | }() 165 | 166 | time.Sleep(150 * time.Millisecond) 167 | 168 | if atomic.LoadUint64(&counter) != 1 { 169 | t.Errorf("unexpected number of calls to the genetator. have %d, want %d", atomic.LoadUint64(&counter), 1) 170 | } 171 | if err = ioutil.WriteFile(name+"/test", []byte("12345678"), 0644); err != nil { 172 | t.Error(err) 173 | return 174 | } 175 | 176 | time.Sleep(150 * time.Millisecond) 177 | 178 | if atomic.LoadUint64(&counter) != 2 { 179 | t.Errorf("unexpected number of calls to the genetator. have %d, want %d", atomic.LoadUint64(&counter), 2) 180 | } 181 | 182 | cancel() 183 | } 184 | 185 | type erroredGenerator struct { 186 | err error 187 | } 188 | 189 | func (e erroredGenerator) Generate(_ string) error { 190 | return e.err 191 | } 192 | 193 | type spyGenerator struct { 194 | want string 195 | } 196 | 197 | func (s spyGenerator) Generate(have string) error { 198 | if s.want != have { 199 | return fmt.Errorf("generating. have: %s, want: %s", have, s.want) 200 | } 201 | return nil 202 | } 203 | -------------------------------------------------------------------------------- /engine/handler.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/gin-gonic/gin" 11 | newrelic "github.com/newrelic/go-agent" 12 | nrgin "github.com/newrelic/go-agent/_integrations/nrgin/v1" 13 | ) 14 | 15 | // HandlerConfig defines a Handler 16 | type HandlerConfig struct { 17 | // Page contains the page description 18 | Page Page 19 | // Renderer is the component responsible for rendering the responses 20 | Renderer Renderer 21 | // ResponseGenerator gets the data required for generating a response 22 | // it can get it from a static, local source or from a remote api 23 | // endpoint 24 | ResponseGenerator ResponseGenerator 25 | // CacheControl is the Cache-Control string added into the response headers 26 | // if everything goes ok 27 | CacheControl string 28 | } 29 | 30 | // DefaultHandlerConfig contains the dafult values for a HandlerConfig 31 | var DefaultHandlerConfig = HandlerConfig{ 32 | Page{}, 33 | EmptyRenderer, 34 | NoopResponse, 35 | "public, max-age=3600", 36 | } 37 | 38 | // Default404StaticHandler is the default static handler for dealing with 404 errors 39 | var Default404StaticHandler = StaticHandler{[]byte(default404Tmpl)} 40 | 41 | // Default500StaticHandler is the default static handler for dealing with 500 errors 42 | var Default500StaticHandler = ErrorHandler{[]byte(default500Tmpl), http.StatusInternalServerError} 43 | 44 | // NewHandlerConfig creates a HandlerConfig from the given Page definition 45 | func NewHandlerConfig(page Page) HandlerConfig { 46 | d, err := time.ParseDuration(page.CacheTTL) 47 | if err != nil { 48 | d = time.Hour 49 | } 50 | cacheTTL := fmt.Sprintf("public, max-age=%d", int(d.Seconds())) 51 | 52 | if page.BackendURLPattern == "" { 53 | rg := StaticResponseGenerator{page} 54 | return HandlerConfig{ 55 | page, 56 | DefaultHandlerConfig.Renderer, 57 | rg.ResponseGenerator, 58 | cacheTTL, 59 | } 60 | } 61 | 62 | decoder := JSONDecoder 63 | if page.IsArray { 64 | decoder = JSONArrayDecoder 65 | } 66 | rg := DynamicResponseGenerator{page, CachedClient(page.BackendURLPattern), decoder} 67 | 68 | return HandlerConfig{ 69 | page, 70 | DefaultHandlerConfig.Renderer, 71 | rg.ResponseGenerator, 72 | cacheTTL, 73 | } 74 | } 75 | 76 | // NewHandler creates a Handler with the given configuration. The returned handler will be keeping itself 77 | // subscribed to the latest template updates using the given subscription channel, allowing hot 78 | // template reloads 79 | func NewHandler(cfg HandlerConfig, subscriptionChan chan Subscription) *Handler { 80 | h := &Handler{ 81 | cfg.Page, 82 | cfg.Renderer, 83 | make(chan Renderer), 84 | subscriptionChan, 85 | cfg.ResponseGenerator, 86 | cfg.CacheControl, 87 | } 88 | go h.updateRenderer() 89 | return h 90 | } 91 | 92 | // Handler is a struct that combines a renderer and a response generator for handling 93 | // http requests. 94 | // 95 | // The handler is able to keep itself subscribed to the last renderer version to use 96 | // by wrapping its Input channel into a Subscription and sending it through the Subscribe 97 | // channel every time it gets a new Renderer 98 | type Handler struct { 99 | Page Page 100 | Renderer Renderer 101 | Input chan Renderer 102 | Subscribe chan Subscription 103 | ResponseGenerator ResponseGenerator 104 | CacheControl string 105 | } 106 | 107 | func (h *Handler) updateRenderer() { 108 | topic := h.Page.Template 109 | if h.Page.Layout != "" { 110 | topic = fmt.Sprintf("%s-:-%s", h.Page.Layout, h.Page.Template) 111 | } 112 | for { 113 | h.Subscribe <- Subscription{topic, h.Input} 114 | h.Renderer = <-h.Input 115 | } 116 | } 117 | 118 | // HandlerFunc handles a gin request rendering the data returned by the response generator. 119 | // If the response generator does not return an error, it adds a Cache-Control header 120 | func (h *Handler) HandlerFunc(c *gin.Context) { 121 | if newrelicApp != nil { 122 | nrgin.Transaction(c).SetName(h.Page.Name) 123 | } 124 | result, err := h.ResponseGenerator(c) 125 | if err != nil { 126 | c.AbortWithError(http.StatusInternalServerError, err) 127 | return 128 | } 129 | if newrelicApp != nil { 130 | defer newrelic.StartSegment(nrgin.Transaction(c), "Render").End() 131 | } 132 | c.Header("Cache-Control", h.CacheControl) 133 | if err := h.Renderer.Render(c.Writer, result); err != nil { 134 | c.AbortWithError(http.StatusInternalServerError, err) 135 | return 136 | } 137 | } 138 | 139 | // NewStaticHandler creates a StaticHandler using the content of the received path 140 | func NewStaticHandler(path string) (StaticHandler, error) { 141 | data, err := ioutil.ReadFile(path) 142 | if err != nil { 143 | log.Println("reading", path, ":", err.Error()) 144 | return StaticHandler{}, err 145 | } 146 | return StaticHandler{data}, nil 147 | } 148 | 149 | // StaticHandler is a Handler that writes the injected content 150 | type StaticHandler struct { 151 | Content []byte 152 | } 153 | 154 | // HandlerFunc creates a gin handler that does nothing but writing the static content 155 | func (e *StaticHandler) HandlerFunc() gin.HandlerFunc { 156 | return func(c *gin.Context) { 157 | if newrelicApp != nil { 158 | nrgin.Transaction(c).SetName("StaticHandler") 159 | } 160 | c.Writer.Write(e.Content) 161 | } 162 | } 163 | 164 | // NewErrorHandler creates a ErrorHandler using the content of the received path 165 | func NewErrorHandler(path string, code int) (ErrorHandler, error) { 166 | data, err := ioutil.ReadFile(path) 167 | if err != nil { 168 | log.Println("reading", path, ":", err.Error()) 169 | return ErrorHandler{}, err 170 | } 171 | return ErrorHandler{data, code}, nil 172 | } 173 | 174 | // ErrorHandler is a Handler that writes the injected content. It's intended to be dispatched 175 | // by the gin special handlers (NoRoute, NoMethod) but they can also be used as regular handlers 176 | type ErrorHandler struct { 177 | Content []byte 178 | ErrorCode int 179 | } 180 | 181 | // HandlerFunc is a gin middleware for dealing with some errors 182 | func (e *ErrorHandler) HandlerFunc() gin.HandlerFunc { 183 | return func(c *gin.Context) { 184 | c.Next() 185 | 186 | if !c.IsAborted() || c.Writer.Status() != e.ErrorCode { 187 | return 188 | } 189 | 190 | c.Writer.Write(e.Content) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /engine/response_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | func ExampleResponseContext_String() { 16 | r := ResponseContext{ 17 | Data: map[string]interface{}{ 18 | "a": "foo", 19 | "b": 42, 20 | }, 21 | Params: map[string]string{"p1": "v1"}, 22 | Extra: map[string]interface{}{ 23 | "extra1": "foo", 24 | "extra2": 42, 25 | }, 26 | } 27 | fmt.Println(r.String()) 28 | // Output: 29 | // { 30 | // "Data": { 31 | // "a": "foo", 32 | // "b": 42 33 | // }, 34 | // "Array": null, 35 | // "Extra": { 36 | // "extra1": "foo", 37 | // "extra2": 42 38 | // }, 39 | // "Params": { 40 | // "p1": "v1" 41 | // } 42 | // } 43 | } 44 | 45 | func TestNoopResponse(t *testing.T) { 46 | gin.SetMode(gin.TestMode) 47 | e := gin.New() 48 | e.GET("/", func(c *gin.Context) { 49 | resp, err := NoopResponse(c) 50 | if err != ErrNoResponseGeneratorDefined { 51 | t.Error("unexpected error:", err) 52 | } 53 | if len(resp.Array) != 0 { 54 | t.Errorf("unexpected response: %v", resp) 55 | } 56 | if len(resp.Data) != 0 { 57 | t.Errorf("unexpected response: %v", resp) 58 | } 59 | c.Status(200) 60 | }) 61 | 62 | w := httptest.NewRecorder() 63 | r, _ := http.NewRequest("GET", "/", nil) 64 | e.ServeHTTP(w, r) 65 | if w.Result().StatusCode != 200 { 66 | t.Errorf("unexpected status code: %d", w.Result().StatusCode) 67 | } 68 | } 69 | 70 | func TestStaticResponseGenerator(t *testing.T) { 71 | subject := StaticResponseGenerator{Page{Extra: map[string]interface{}{"a": 42.0}}} 72 | gin.SetMode(gin.TestMode) 73 | e := gin.New() 74 | e.GET("/:first/:second", func(c *gin.Context) { 75 | resp, err := subject.ResponseGenerator(c) 76 | if err != nil { 77 | t.Error("unexpected error:", err.Error()) 78 | return 79 | } 80 | checkCommonResponseProperties(t, resp) 81 | c.Status(200) 82 | }) 83 | 84 | w := httptest.NewRecorder() 85 | r, _ := http.NewRequest("GET", "/foo/bar", nil) 86 | e.ServeHTTP(w, r) 87 | if w.Result().StatusCode != 200 { 88 | t.Errorf("unexpected status code: %d", w.Result().StatusCode) 89 | } 90 | } 91 | 92 | func TestDynamicResponseGenerator_koBackend(t *testing.T) { 93 | backendErr := fmt.Errorf("backendErr") 94 | expectedHeader := []string{"Header-Key", "header value"} 95 | subject := DynamicResponseGenerator{ 96 | Page: Page{ 97 | Extra: map[string]interface{}{"a": 42.0}, 98 | Header: expectedHeader[0], 99 | }, 100 | Decoder: JSONDecoder, 101 | Backend: func(params map[string]string, headers map[string]string, _ *gin.Context) (*http.Response, error) { 102 | if params["first"] != "foo" || params["second"] != "bar" { 103 | t.Error("unexpected params:", params) 104 | } 105 | if h, ok := headers[expectedHeader[0]]; !ok || h != expectedHeader[1] { 106 | t.Error("unexpected headers:", headers) 107 | } 108 | return nil, backendErr 109 | }, 110 | } 111 | gin.SetMode(gin.TestMode) 112 | e := gin.New() 113 | e.GET("/:first/:second", func(c *gin.Context) { 114 | _, err := subject.ResponseGenerator(c) 115 | if err != backendErr { 116 | t.Error("unexpected error:", err) 117 | return 118 | } 119 | c.Status(200) 120 | }) 121 | 122 | w := httptest.NewRecorder() 123 | r, _ := http.NewRequest("GET", "/foo/bar", nil) 124 | r.Header.Set(expectedHeader[0], expectedHeader[1]) 125 | e.ServeHTTP(w, r) 126 | if w.Result().StatusCode != 200 { 127 | t.Errorf("unexpected status code: %d", w.Result().StatusCode) 128 | } 129 | } 130 | 131 | func TestDynamicResponseGenerator_koDecoder(t *testing.T) { 132 | decoderErr := fmt.Errorf("decoderErr") 133 | expectedResponse := "abcd" 134 | subject := DynamicResponseGenerator{ 135 | Page: Page{Extra: map[string]interface{}{"a": 42.0}}, 136 | Backend: func(_ map[string]string, _ map[string]string, _ *gin.Context) (*http.Response, error) { 137 | return &http.Response{Body: ioutil.NopCloser(bytes.NewBufferString(expectedResponse))}, nil 138 | }, 139 | Decoder: func(r io.Reader, c *ResponseContext) error { 140 | p := &bytes.Buffer{} 141 | p.ReadFrom(r) 142 | if p.String() != expectedResponse { 143 | t.Error("unexpected response:", p.String()) 144 | } 145 | return decoderErr 146 | }, 147 | } 148 | gin.SetMode(gin.TestMode) 149 | e := gin.New() 150 | e.GET("/:first/:second", func(c *gin.Context) { 151 | _, err := subject.ResponseGenerator(c) 152 | if err != decoderErr { 153 | t.Error("unexpected error:", err) 154 | return 155 | } 156 | c.Status(200) 157 | }) 158 | 159 | w := httptest.NewRecorder() 160 | r, _ := http.NewRequest("GET", "/foo/bar", nil) 161 | e.ServeHTTP(w, r) 162 | if w.Result().StatusCode != 200 { 163 | t.Errorf("unexpected status code: %d", w.Result().StatusCode) 164 | } 165 | } 166 | 167 | func TestDynamicResponseGenerator_ok(t *testing.T) { 168 | expectedResponse := "abcd" 169 | subject := DynamicResponseGenerator{ 170 | Page: Page{Extra: map[string]interface{}{"a": 42.0}}, 171 | Backend: func(_ map[string]string, _ map[string]string, _ *gin.Context) (*http.Response, error) { 172 | return &http.Response{Body: ioutil.NopCloser(bytes.NewBufferString(expectedResponse))}, nil 173 | }, 174 | Decoder: func(r io.Reader, c *ResponseContext) error { 175 | p := &bytes.Buffer{} 176 | p.ReadFrom(r) 177 | if p.String() != expectedResponse { 178 | t.Error("unexpected response:", p.String()) 179 | } 180 | c.Data = map[string]interface{}{"a": true} 181 | return nil 182 | }, 183 | } 184 | gin.SetMode(gin.TestMode) 185 | e := gin.New() 186 | e.GET("/:first/:second", func(c *gin.Context) { 187 | resp, err := subject.ResponseGenerator(c) 188 | if err != nil { 189 | t.Error("unexpected error:", err.Error()) 190 | return 191 | } 192 | checkCommonResponseProperties(t, resp) 193 | 194 | if d, ok := resp.Data["a"].(bool); !ok || !d { 195 | t.Errorf("unexpected response. data: %v", resp.Data) 196 | return 197 | } 198 | c.Status(200) 199 | }) 200 | 201 | w := httptest.NewRecorder() 202 | r, _ := http.NewRequest("GET", "/foo/bar", nil) 203 | e.ServeHTTP(w, r) 204 | if w.Result().StatusCode != 200 { 205 | t.Errorf("unexpected status code: %d", w.Result().StatusCode) 206 | } 207 | } 208 | 209 | func checkCommonResponseProperties(t *testing.T, resp ResponseContext) { 210 | if 42.0 != resp.Extra["a"].(float64) { 211 | t.Errorf("unexpected response. extra: %v", resp.Extra) 212 | return 213 | } 214 | if v, ok := resp.Params["first"]; !ok || v != "foo" { 215 | t.Errorf("unexpected response. first param: %v", resp.Params["first"]) 216 | return 217 | } 218 | if v, ok := resp.Params["second"]; !ok || v != "bar" { 219 | t.Errorf("unexpected response. second param: %v", resp.Params["second"]) 220 | return 221 | } 222 | if resp.Context == nil { 223 | t.Error("nil response context!") 224 | return 225 | } 226 | if v, ok := resp.Helper.(*tplHelper); !ok || v == nil { 227 | t.Errorf("unexpected response. helper: %v", resp.Helper) 228 | return 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /engine/handler_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "testing" 11 | "time" 12 | 13 | "github.com/gin-gonic/gin" 14 | ) 15 | 16 | func TestNewStaticHandler(t *testing.T) { 17 | fileName := fmt.Sprintf("testErrorHAndler-%d", time.Now().Unix()) 18 | data := []byte("sample data to be dumped by the error handler") 19 | err := ioutil.WriteFile(fileName, data, 0666) 20 | if err != nil { 21 | t.Error(err) 22 | return 23 | } 24 | defer os.Remove(fileName) 25 | 26 | eh, err := NewStaticHandler(fileName) 27 | if err != nil { 28 | t.Error(err) 29 | return 30 | } 31 | 32 | gin.SetMode(gin.TestMode) 33 | engine := gin.New() 34 | engine.GET("/static", eh.HandlerFunc()) 35 | 36 | w := httptest.NewRecorder() 37 | req, err := http.NewRequest("GET", "/static", nil) 38 | if err != nil { 39 | t.Error(err) 40 | return 41 | } 42 | engine.ServeHTTP(w, req) 43 | 44 | if w.Result().StatusCode != 200 { 45 | t.Errorf("unexpected status code: %d", w.Result().StatusCode) 46 | } 47 | res, err := ioutil.ReadAll(w.Result().Body) 48 | if err != nil { 49 | t.Error(err) 50 | return 51 | } 52 | w.Result().Body.Close() 53 | if string(res) != string(data) { 54 | t.Errorf("unexpected response content: %s", string(res)) 55 | } 56 | } 57 | 58 | func TestNewErrorHandler(t *testing.T) { 59 | fileName := fmt.Sprintf("testErrorHAndler-%d", time.Now().Unix()) 60 | data := []byte("sample data to be dumped by the error handler") 61 | err := ioutil.WriteFile(fileName, data, 0666) 62 | if err != nil { 63 | t.Error(err) 64 | return 65 | } 66 | defer os.Remove(fileName) 67 | 68 | eh, err := NewErrorHandler(fileName, 987) 69 | if err != nil { 70 | t.Error(err) 71 | return 72 | } 73 | 74 | gin.SetMode(gin.TestMode) 75 | engine := gin.New() 76 | engine.GET("/middleware/ok", eh.HandlerFunc(), func(c *gin.Context) { c.String(200, "hi there!") }) 77 | engine.GET("/middleware/ko", eh.HandlerFunc(), func(c *gin.Context) { c.AbortWithStatus(987) }) 78 | 79 | w := httptest.NewRecorder() 80 | req, err := http.NewRequest("GET", "/middleware/ok", nil) 81 | if err != nil { 82 | t.Error(err) 83 | return 84 | } 85 | engine.ServeHTTP(w, req) 86 | 87 | if w.Result().StatusCode != 200 { 88 | t.Errorf("unexpected status code: %d", w.Result().StatusCode) 89 | } 90 | res, err := ioutil.ReadAll(w.Result().Body) 91 | if err != nil { 92 | t.Error(err) 93 | return 94 | } 95 | w.Result().Body.Close() 96 | if string(res) != "hi there!" { 97 | t.Errorf("unexpected response content: %s", string(res)) 98 | } 99 | 100 | w = httptest.NewRecorder() 101 | req, err = http.NewRequest("GET", "/middleware/ko", nil) 102 | if err != nil { 103 | t.Error(err) 104 | return 105 | } 106 | engine.ServeHTTP(w, req) 107 | 108 | if w.Result().StatusCode != 987 { 109 | t.Errorf("unexpected status code: %d", w.Result().StatusCode) 110 | } 111 | res, err = ioutil.ReadAll(w.Result().Body) 112 | if err != nil { 113 | t.Error(err) 114 | return 115 | } 116 | w.Result().Body.Close() 117 | if string(res) != string(data) { 118 | t.Errorf("unexpected response content: %s", string(res)) 119 | } 120 | } 121 | 122 | func TestNewStaticHandler_ko(t *testing.T) { 123 | _, err := NewStaticHandler("unknown_file_not_present_in_the_fs") 124 | if err == nil { 125 | t.Error("error expected") 126 | } 127 | } 128 | 129 | func TestNewErrorHandler_ko(t *testing.T) { 130 | _, err := NewErrorHandler("unknown_file_not_present_in_the_fs", 123) 131 | if err == nil { 132 | t.Error("error expected") 133 | } 134 | } 135 | 136 | func TestNewHandler(t *testing.T) { 137 | responseCtx := ResponseContext{ 138 | Array: []map[string]interface{}{ 139 | {"a": "foo"}, 140 | }, 141 | } 142 | layout := "layout" 143 | templateName := "name" 144 | responseBody := "some response content" 145 | cfg := HandlerConfig{ 146 | Renderer: EmptyRenderer, 147 | ResponseGenerator: func(_ *gin.Context) (ResponseContext, error) { 148 | return responseCtx, nil 149 | }, 150 | Page: Page{ 151 | Template: templateName, 152 | Layout: layout, 153 | }, 154 | } 155 | subscriptionChan := make(chan Subscription) 156 | h := NewHandler(cfg, subscriptionChan) 157 | 158 | gin.SetMode(gin.TestMode) 159 | engine := gin.New() 160 | engine.GET("/", h.HandlerFunc) 161 | 162 | w := httptest.NewRecorder() 163 | req, err := http.NewRequest("GET", "/", nil) 164 | if err != nil { 165 | t.Error(err) 166 | return 167 | } 168 | engine.ServeHTTP(w, req) 169 | 170 | if w.Result().StatusCode != 500 { 171 | t.Errorf("unexpected status code: %d", w.Result().StatusCode) 172 | } 173 | res, err := ioutil.ReadAll(w.Result().Body) 174 | if err != nil { 175 | t.Error(err) 176 | return 177 | } 178 | w.Result().Body.Close() 179 | if string(res) != "" { 180 | t.Errorf("unexpected response content: %s", string(res)) 181 | } 182 | 183 | subscription := <-subscriptionChan 184 | if subscription.Name != layout+"-:-"+templateName { 185 | t.Errorf("unexpected subscription topic: %s", subscription.Name) 186 | return 187 | } 188 | subscription.In <- RendererFunc(func(w io.Writer, v interface{}) error { 189 | if tmp, ok := v.(ResponseContext); !ok { 190 | t.Errorf("unexpected type %t", v) 191 | return nil 192 | } else if len(tmp.Array) != 1 { 193 | t.Errorf("unexpected value %v", tmp) 194 | return nil 195 | } 196 | _, err = w.Write([]byte(responseBody)) 197 | return err 198 | }) 199 | <-subscriptionChan 200 | 201 | w = httptest.NewRecorder() 202 | req, err = http.NewRequest("GET", "/", nil) 203 | if err != nil { 204 | t.Error(err) 205 | return 206 | } 207 | engine.ServeHTTP(w, req) 208 | 209 | if w.Result().StatusCode != 200 { 210 | t.Errorf("unexpected status code: %d", w.Result().StatusCode) 211 | } 212 | res, err = ioutil.ReadAll(w.Result().Body) 213 | if err != nil { 214 | t.Error(err) 215 | return 216 | } 217 | w.Result().Body.Close() 218 | if string(res) != responseBody { 219 | t.Errorf("unexpected response content: %s", string(res)) 220 | } 221 | } 222 | 223 | func TestNewHandler_ko(t *testing.T) { 224 | cfg := HandlerConfig{ 225 | Renderer: EmptyRenderer, 226 | ResponseGenerator: NoopResponse, 227 | Page: Page{}, 228 | } 229 | subscriptionChan := make(chan Subscription) 230 | h := NewHandler(cfg, subscriptionChan) 231 | 232 | gin.SetMode(gin.TestMode) 233 | engine := gin.New() 234 | engine.GET("/", h.HandlerFunc) 235 | 236 | w := httptest.NewRecorder() 237 | req, err := http.NewRequest("GET", "/", nil) 238 | if err != nil { 239 | t.Error(err) 240 | return 241 | } 242 | engine.ServeHTTP(w, req) 243 | 244 | if w.Result().StatusCode != 500 { 245 | t.Errorf("unexpected status code: %d", w.Result().StatusCode) 246 | } 247 | res, err := ioutil.ReadAll(w.Result().Body) 248 | if err != nil { 249 | t.Error(err) 250 | return 251 | } 252 | w.Result().Body.Close() 253 | if string(res) != "" { 254 | t.Errorf("unexpected response content: %s", string(res)) 255 | } 256 | } 257 | 258 | func TestNewHandlerConfig_StaticResponseGenerator(t *testing.T) { 259 | cfg := NewHandlerConfig(Page{Name: "name"}) 260 | if cfg.CacheControl != "public, max-age=3600" { 261 | t.Errorf("unexpected cache control: %s", cfg.CacheControl) 262 | } 263 | if cfg.Page.Name != "name" { 264 | t.Errorf("unexpected page config: %v", cfg.Page) 265 | } 266 | } 267 | 268 | func TestNewHandlerConfig_DynamicResponseGenerator(t *testing.T) { 269 | cfg := NewHandlerConfig(Page{Name: "name", IsArray: true, BackendURLPattern: "http://example.com"}) 270 | if cfg.CacheControl != "public, max-age=3600" { 271 | t.Errorf("unexpected cache control: %s", cfg.CacheControl) 272 | } 273 | if cfg.Page.Name != "name" { 274 | t.Errorf("unexpected page config: %v", cfg.Page) 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /statik/statik.go: -------------------------------------------------------------------------------- 1 | // Code generated by statik. DO NOT EDIT. 2 | 3 | package statik 4 | 5 | import ( 6 | "github.com/rakyll/statik/fs" 7 | ) 8 | 9 | func init() { 10 | data := "PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1c\x00\x00\x00blog/config/es_ES/config.iniD\xc9\xc1 \x800\x0c\x00\xc0\x7f\xa6\xc8\x02\xbaA\xa7\x10_\xa5H\xa8\xc1\x06\xda\xa4\x98\xb8\xbf?}\x1e\x97]\x82\x0b\x88\xdb\xd1I/L\xc8\xba\xec\x1b|\x00\xc8\xc1cv\n\xf6\x02\xa2R\xc50a\xb3\xc1\xebx<\xa86\x06\xd6\xb8\xe9$\xc7\x84\xd3<\xfex\x03\x00\x00\xff\xffPK\x07\x08\x00T\x04\xa3R\x00\x00\x00_\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1c\x00\x00\x00blog/config/es_ES/routes.ini\x8a.\xca/-I\xd5\xcd\xc8\xcfM\x8d\xe5\xcaM-ILI,I\x8c/\xc9,\xc9IU\xb0U\xf0\xcc\xcbL\xce\xcc\xe7\xca,\x8e\x07)P\xb0U()*M\xe5\xe2\x82j*\xc8/.)\xc6\xa2\xcb5\xaf\xa4(1%\xb1\x98\x0b\x10\x00\x00\xff\xffPK\x07\x08\xcc\xb8?\xf3K\x00\x00\x00]\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1d\x00\x00\x00blog/config/global/config.ini|\x90\xcfJ\xc3@\x10\x87\xef\xf3\x14s\xd4CM\xec)\x14\xf6 ZP\xb0 \xd4\x9eB\x08\xd3t\xc9.\xec?2\x93C\x1e\xc9\xa3\xaf\xa0/&\xdbh\xabP<\xfd\x98aw\xbe\xf9\xa6f+\xba\x81@^\xa3\xc2\xbb\x97\xa7\xe5\xe3\xeb\xe6\x19\x1f\xb4\x8f\xb0'\xd6\xed88TX\x80\xe5\xd8:\n=*\xd4a\xb1\xdb\xc2\xa9\x80qp-\x0b\x89\xedP\xa1\x11I\xab\xa2p\xb1#g\"\xcb\xaa*\xab\x12\xba\x98\xa6\xc1\xf6FP!~\xbc\xe1\xb2\xbc\xadp3\xe1}\xf4\x89\xc2\x04P\x8b\xf6\xc9\x91hn\xc0\xc4\xe329n\xfc\xc8B\x9d\xd1\x90\"\xe7\xcf9\xceM\xa8\x1dMq\x14n\xc0\x93\x0d\xa80G;7\xff>\x0b}{\xdc\xfb\xe4:[\x1c\xec\x80\n\x9d\x0cp\x88\xdf#t\x00G{\x9d\xb5\xd7\xa1w\x96\x0d^\xed\xb6\xd70_\xe2\xb2\xdf\x0f\x81\x17\xeb_\x84\\]$\xf0\x99\xc0\x89>\xdf\xa3\xfbw\xfaW\x00\x00\x00\xff\xffPK\x07\x08?\xf2fp\xf5\x00\x00\x00\xa5\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1d\x00\x00\x00blog/config/global/routes.ini\x8a.\xca/-I\xd5\xcd\xc8\xcfM\x8d\xe5\xcaM-ILI,I\x8c/\xc9,\xc9IU\xb0U\xf0\xc8\xcfM\xe5\xca,\x8e\x07I+\xd8*\x94\x14\x95\xa6rqA\xb5\x14\xe4\x17\x97\x14c\xd1\x13\x90_\\\xc2\x05\x08\x00\x00\xff\xffPK\x07\x08X\xbe\x1dKE\x00\x00\x00W\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x13\x00\x00\x00blog/i18n/en_US.ini\x1c\xc9=\nC!\x10E\xe1\xfe\xae\xe2\xba\x10\xfb\x94\xe9E\x82!\x93\x1fPG2#\xbc\xb7\xfb\x87V\x1f\x9c\x93\x9e\xc5$C\x8e\xd2F\x15F\xde~\x01H\xf3_3\x86\x9a\x1b#\xb7\xf8j[\x7f\x01$;\xcd\xa5et\xf5\xc7[g\x7f1\xf2^>\xc2\xae\xce\x1d\x02\xae\x00\x00\x00\xff\xffPK\x07\x08\xf9\xfcp\xd3V\x00\x00\x00\\\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x13\x00\x00\x00blog/i18n/es_ES.ini$\xcb1\x0e\xc20\x0c\x05\xd0\xfd\x9f\xc2=HvF\xf6(B\xa65\x10)\xf1\xafjW\x82\xdb#\xc4\xfe^\xbdkX\x83\xbdu\xee\xc3\xa4\xc8\x85C\x17\xa0\x9e\xc7h\xd8\x19\x19R\xc4<\x0f\xdd4\xf0\xe2\xfc\xa1\xee}\xed\x04j|\"m68\xf3\xf6\xe0\xe9\x9b\x14\xb9\xea\xb3\xbb\x8aS\xccW\xfe\xe7\x82o\x00\x00\x00\xff\xffPK\x07\x08\xd0\xd3 :`\x00\x00\x00i\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1d\x00\x00\x00blog/sources/es_ES/static/404t\x90\xd1n\xd30\x14\x86\xaf\xe9Sx\x96z\x85\x12\xb7,\xab*\xe4D\xaa\xca6m\x9aFa\x93Z\xae\xd0\xa9s\x92\x18\x12;\xf59\xd9\x1a\xdef\xcf\xb2\x17CIa\\q\x15\xe5\xf3\xf7\xff\x96\x7f}\xf6\xe9\xf3\xfa\xf1\xdb\xe6RT\xdc\xd4\xd9D\x0f\x1fQ\x83+S\xa9\xa7k\xef\n[\xc6d\x19\xe3\x81M39(\x08y6y\xa7\x1bd\x10\xa6\x82@\xc8\xa9\xec\xb8\x88\x96\xf2\x8dW\xccm\x84\x87\xce>\xa5r\xed\x1d\xa3\xe3\xe8\x0e\\\xd9A\x89R\x98\x13\xf9\xcf\x1dB\x0d5\xb5u?E\xc0:\x95\xc4}\x8dT!\xb2\x14U\xc0\"\x95C;}T\xaa\x81\xa3\xc9]\xbc\xf7\x9e\x89\x03\xb4\xc3\x8f\xf1\x8dz\x03*\x89g\xf1L\x19\xa2\x7f,n\xac\x8b\x0d\x91\x14\xd61\x96\xc1r\x9fJ\xaa\xe0|\x99D\xd7\xee\xe2|\x99\x1c\x0f_\xe6\xe0\xb7\xbb\xd5\xfb\xd9\xc5\xf2\xebns\xdc\x94\x8b\xa2On\xb6O\x8f\xf7\xd5\xec\xf2\xc3\xe2|\xd7\\\x99\xdb\xfaa\xf5l\xaf\xcb\xab\xd5V\xe5+\xfb\xb0\xb8\xdd5R\x98\xe0\x89|\xb0\xa5u\xa9\x04\xe7]\xdf\xf8\x8e\xc6a\xd8r\x8d\x99\x9e\x8a\x9b\xf9\xf2>\xa6\x9e\x18\x9b\xd8y\xfe^\xf8\xce\xe5b\x9aiuR&Z\x9dF\xd6{\x9f\xf7\xc2\xd4@\x94J\xc6#G\x06\x1dc\x18\xeb\xaa\xf9\xdf\x93\xa6\x8f.d\xb6y})\xad\x03\xe1\xbc@7,\x1c \x873\xad\xaa\xf9`\xb7\xd9\x1d\x88\xf6\x8fr\xe8P\xec;2@\xa3M\xfc\xfa\"\xa0\xf6? \x07\x81n\x00 \x9eq\x7f\xa6U{\xcan:\xccq\x8c\x1d:\x8b\x01H\xb4\x18\xc8;\xa8\xed/\x08C\x00EaM\x85\xc1\x0b\xcc-\x83\xcb\xbd\xd0\xc6\xe7\x98\x11\x03[\xa3\x92Y\xa2\xd5\x08\xc6R\xad\x86\xa7e\x93\xdf\x01\x00\x00\xff\xffPK\x07\x08\xc1\xe6\xa7H\xbe\x01\x00\x00\x81\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1d\x00\x00\x00blog/sources/es_ES/static/500t\x91\xc1n\x13=\x10\xc7\xcf_\x9ebj\xa9\xdf\x05e\x9d\xd2\xa4\x8a`w\xa5P\xda\xd2\xaa\x82@+5\x1c'\xde\xc9\xae\x85\xd7\xdexfC\xb6\xef\xc4S\xf0b\xc8\x89(\x178X\xd6\xfc\xc6\xf3\xb3\xf4\x9f\xfc\xe4\xfd\xa7\xcb\xc7\xaf\xcb+h\xa4u\xe5(O\x178\xf4u\xa1\xf2\xd3\xcb\xe07\xb6\xce\xd8\ne\x89\x9d\x96*=!\xac\xca\xd1\x7fyK\x82`\x1a\x8cLR\xa8^6\xe3\xb9z\xe1\x8dH7\xa6mow\x85\xba\x0c^\xc8\xcb\xf8\x1e}\xddcM\n\xcc\x91\xfc\xe3\x0f\xd0I\xe3\xac\xff\x06\x91\\\xa1X\x06G\xdc\x10\x89\x82&\xd2\xa6P\xc9\xceo\xb4nqo*\x9f\xadC\x10\x96\x88]*Lh\xf5\x0b\xd0\xd3l\x92M\xb4a\xfe\xc3\xb2\xd6\xfa\xcc0+\xb0^\xa8\x8eV\x86Bq\x83\xe7\xf3\xe9\xf8\xc6\xcf\xce\xe7\xd3\xfd\xf6\xf3\x19\x86\xa7\xd5\xe2\xd5d6\xff\xb2Z\xee\x97\xf5\xc5f\x98\xde>\xed\x1e?6\x93\xab\xd7\x17\xe7\xab\xf6\xda\xdc\xb9\x87\xc5w{S_/\x9et\xb5\xb0\x0f\x17w\xabV\x81\x89\x819D[[_(\xf4\xc1\x0fm\xe8\xf9\x10\x8cXqT\xbe\x0b\xa1=\xc9\xf5\xb1\x18\xe5\xfa\x18g\xbe\x0e\xd5\x00\xc6!s\xa1\x84\xf626\xe4\x85\xe2a\xb09\xfb\xddi\x87\xf1L\x95\xf7\x08\x91\xb8\xeb\x89\x05\xa1\"p\x08\x8b\xe5-0\xc1.\xb8\x9d\xfd?\xa0\xe9\x85\xde\x82\x0b\x06Or\xdd\x9c%IW~\xe8\xd7\x01z\x0f]\x0ckG-\xa65\xa4\xd9\xbf\xb9\x06\xd8\xf4?\x7f\x80\xf5\x06;|N\x8d.\x06C\x8c\xd1a\x96\xeb\xeeh\\\xf6T\x11l\xfbt,Ed\xe8(r\xf0\xe8\xec3F \x16\x82\x8d5\x0d\xc5\x00TYA_\x05\xc8M\xa8\xa8dA\xb1F\xcf&\x93\\\x1f\xc0A\x9a\xeb\x94C9\xfa\x15\x00\x00\xff\xffPK\x07\x08\xc8\xe3\xa8\xf8\xc7\x01\x00\x00\x98\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00%\x00\x00\x00blog/sources/es_ES/tmpl/home.mustache\x94\x94\xdd\x8e\xdc4\x14\x80\xafw\x9f\xe24\xc8W\x90I\xc6\xd9YJ\x9b\x194\xbb\xdd\x11\xbd\x01\x04\xad\xb8@\x08y\xe23\x89[\xc7\x8elg~\x88\xe6ax\x86>\xc2\xbe\x18\xb2\x9dlwa\x85 \x17\xb1}|\xcew|~\xecaX\x96\x04\xc8jy>\x97\\\xec\xa1\x92\xcc\xdae\xf2\xa1o\xb7\xda\x19\xad\xa0K\x0b\xe8\xd2\x96\xa7\x0bpxt\xe9\xa1\x11\x0e\xc1\xe8^q\xe4\xb0\xadS\xce\xcc\xc7du \xe3\xf7\x98Ri\xe9-\xaf\xa1;\xa6\xf9#\x1d\x80\xb2\x99OJ\\\xd8N\xb2Sz\x05;\xad\\*\x1c\x93\xa2JV7\x02\xd5\x1e\x95\xe0\x1a\x18H\x06\x07\xdc\x02G\xc0\x0f\xd8vR\xbf(\xb3f\xfe\x04\xd8M<\x89\x8cC{J\x8bdug]4\xec-\x03\xa9-p\xe6\xfc\x1f\xa1W\x0c\xd6?\xbe\x05\xa68\x9c\xc0i\xae\x01%T\xf7\x9f\xb8\xa85\xa0\x85J\xab\x9d\xa8{\xc3*q\xffIy\x1d\xef\x979\xb43X\x03\x17vgz\xc7\xcc\x8b2\xeb\xfe\xed\x18[\x1f\xf7\x9du\xf7\x7fZ\xd8\x0bT\xd1\xcf\xc4\x82\xb2\xd2\x1cWB9\xa3gmo\x1d\xab\x1a,\xb3 |\x02.3.\xf6\xd3r\\\\^\x0e\x03|qwt\x86\xcd8n\xfb\x1a\xce\xe7\xcb\xc7\xd97\xfa\x90\xac./\x9e)\xc8\x9c\xfa\x8d\x8baX\x01\xeb\x04m\\+\xb3\x07\xc4\xc5\xc8\x1f\x87a\x80\xecoN\x9e\xf3\xe2\xcf\xb26\x86\x9d\"\xe2\x99&\x08.\x9fl0\xc3a'\xf1\xe8\xb7\x8d>\xf8l]\xc1V\x1fS\xdb0\xae\x0f\xd0\xf8\x0d\xba\xc8\x83\xe5?L\xd3\xad\xe6'\xe0\xa9'DL\xa5e\xdf*`R\xd4*\x15\x0e[\x9bZ\xc7\x8c\x8b\xf6\x17\xa5\xf5\x0d]?\xf4]*\x94\x14\n\xd3\xad\xd4\xd5G\xef\x9c\xc6\x0e\xef\x8ch\x999%\xab_\xb4\x91\xbc\xcc\xa2\xd9\xc8h\x8a\xc9>\xd66H/J6I\x03!\\ h\x0c\xee\x96IV\x12x;\x7f\xf9\xfd\xac7r\xd6i\xeb,\x90U6\x0c 8\x9c\xcf\xc9j\x18\xc0 '\x11\xce\xe72c\xa3\x97\xac)\xc6\xd9\xa3\xa0\xdbm:\x8f'l{\x87\xdc\xf7Ue\x84\xd3\xd0i\xe3\xbb\xaa\xb7=3BC`\xbf\xb7h\x022\x141\xa0\xba'\xd9\xf3 \x1f4\xeb\x9d\x0e\xa7\x08\xe9\xf4\x16\xdd\xa8\xcf\xfec\x04\xb7Z9\xa1zf@\xe2\xc9\xb7\xf8\x14\xc7g\xe7\xa5h\xeb'\xdeE[\xa7F\xd4\x8d\x8b\x95\xf3\x87\x00\x9e*\xad\x10\xb8\xafz\xa8I\xe2\xef+K\xad\xa9\x96I\xa3%G3\xfb`3\x9a\xe7G\xba\xc8\xbfu\x0d\xb6\xb8tM\xdfn\x13`\xd2-\x93w~\xae\x98\x90\xf0\xeb\xa8\xf4[\x02\xd6\x9d$.\x93\x83\xe0\xaey\x054\xcf\xbb\xe3kh\xd0\xfb~\x05t\xe1\x97 \x04\x17\xde\xd9+\xd1\xb2\x1a3\xbb\xaf\xbf<\xb6\xf2u\xd50c\xd1-\xdf\xbf\xdb\xa4/\xbf\"\xc5\xad\xdd\xd7\x84\xe6\x01F\x8a7\x84R\x9a\xe7\x84RB\xf3\x88\x1c\x85\x8bQxl\xa5\xb2Q\xd68\xd7\x91bM\xe8\x86\xd0\xcd\xe1p\x98\x1d\x8a\x9965\xa1\x1b\x9a{\xc6&\xa0\xbd\xd1^\xe0\xe1F\x1f\xa3YN\x82\x87|\xfcO\xe0\xce\xa0E\xb3\xc7\xb5\xed\xb0r?1't\xd4\xf7)\xf4*\xc5\x1d)n9\xeel\x9c\x85,\x10\x9a\xbbS\x87Q\xd1\xd7\x9f\xd0Me\xed\xa8N\x8b\x98\xe3\xdf\xe7\xd7\xf3\xeb\xfc*\xa7W/\xb7\xde\"\xe8\xe5\xe4\xeb\x1bB\xf3\x9d\x902\x04Q`\x85\x88;R\xdc\x84G\xfb0\x06\xbf\xdej\xc9'\xe1\x8e\xb5B\x9eH\xb1^\x1b\xc1$\xa1\xb7\x84\xe6\xdf\xa1\xdc\xa3\x13\x15\x8b\xcb\x1f:T\x84\xe6?3e\xa3\xc02eS\x8bF\xec\xe2\xba\xd5J\xdb\x8eU8A\xad\xf8\x03I\xb1\x9e\x17]<\xd5\x1b\xff/n}\xfeB\x88!ZB7\x9fC\xf7\x05\x13|,\xc23\x11\x8e\xb92X\xb9\xffS\xda\x98\x8a7aQ,\x16\x8bo\x16\xd5\x03\x8b\xd0M\xc0\x8d\xfe\xc30\xe6q,\xeb\xe2zF\xf3bN\x17\x11v\x8a\xd2y1\x8f\x8c\x87N\x8e\xb0`;\x91\xebgf\xbew\x8a\xbb\xf1\xbe\xc4 S\x83\x8a\xa3A\xbeL\x9c\xe91\xbe\xbe\xe3\x85\x9cF\xff\xb6?<\xda\xa3\xf0\xaf\x00\x00\x00\xff\xffPK\x07\x08}\x1b\xae\xf5\x0f\x04\x00\x00\x11\x08\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1e\x00\x00\x00blog/sources/global/Dockerfiler\x0b\xf2\xf7UHI-\xcb/(NK\xcc,\xc9\xd0O,\xc84\xca(\xc9\xcd\xe1\xe2rtqQ(\xc9-\xc8\xd1\xd7R\xd0O-I\x86\xc8\xe4\x96\xe4\xe8\x83e\x8aK\x12K2\x93Q\xe4Jrs\xf4\xa1\xc2`%\xc9\xf9yi\x99\xe9zY\xc5\xf9yh\xaa\x90d\xb8\xb8\x9c}]\x14\xa2\x15\x94tS\x94t\x14\x94t\x93A$\xaa}H\xaa\x95\x14b\xb9\x00\x01\x00\x00\xff\xffPK\x07\x08n\x08\x0b\xc9r\x00\x00\x00\xb3\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1f\x00\x00\x00blog/sources/global/config.json\xacS\xc1\x8e\x9b0\x10=\xc7_1r\xaf)\xec\xaaRUqk\xf7\xb4RTUUz\xaa*\xe4\x92Ypkld\x0f\xd5F\x88\x7f\xaf\x0c\x06L\x96nr\xe8\x05\xe1\x997\xef=\x1e\xe3\x8e\x01pk~\x1ar<\x03\xb2-\xee}\xc5I\xc2Z4\xab\x12 \x92EN\xcf\x94\x17F\x13j\xe2\x19|g\x00\x00]\xf7\x06\x1e\x8c~\x92e\x12\xa1\x9e\xa4B\x97T\xa8\x94\x81\xbe\xe7]w\x0b&\xbd\nb\x00?\x06C\x8d(\xd1\xf1,X\x18\x9e\x00\\\x8b\x1ay\x06^\xee\xf1\xfe\xc3\xe7\xa4\xb5*i\x8c#\xe7\xf9\xf7\x13\xea\xdb\xd7\xc3\x17A\x84V{l\xba\x05N3\xff\xb6\x8c|\x12\xc5o\xd4\xa7\xf5dE\xd4\xb8,M\x7f9\xa3\x1b%\n\xac\x8c:\xa1M\xe8\xdc\xc8\xc2\x9c0)L\x9d\x0e\x8c\x97tG\xac\x1b%h\xf0\xba\xee\x1c\xc4\xd9\xb4>\\^\x0b\xa9\x97\xfa\x83(*<\x1e\x0f\xbe\xf3\xee\xfd\xdd\x9d\xe3Q\xee\xd6\xb4\x84o'\xef\xf3\x0c>\x93\x15<\x83\xae\x83\x04\xfa>\xcaw=0\xe0\xc3\xd8\xabQV\xa6\xc6\xd7\x92\xfcoym'\xe5\xe5\xaf'\xf5\xe8>Z+\xce\xd1\xf6\xde\x1a`\xf8\xbc\x9b\xf3\x0b\xf81\xbei3#V\n\xde\x87\xed\x9b\x0f\x0b\xe1>b\x8c\xb1l\x17\x91\xa8\xe13\x07\x8a\xf0\xbaM\xb0\xe0\xd8nr\xde\xb1]L\xe5\xefu\"\x9d\xc9\x95\xd0\xe5H\xa8\xcb\xf0\x8f\xff\x05\x89%.\x9bkv_t\x13k;^\xcfm\xa3#n\xc3[kU>^}\xcf\xb3\x9c6<\xae\xa1/\\.\xed\xf0\x7f.\xa5\n\xd3\x9c\xad,+\xf2\xe3\xf3aCh\x05|\xa13w\x83Ld:\xb7\xf8G:i\xfc\xea\xdf\xb3]\xcfz\xf67\x00\x00\xff\xffPK\x07\x08\xeb\x1a\x0d\x12\xb0\x01\x00\x00p\x05\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1e\x00\x00\x00blog/sources/global/static/404t\x91\xd1k\xdb>\x10\xc7\x9f\x7f\xf9+\xae\x82<\xfd\xb0\x95\xacN\x08C6\x84\xac--\xa3\xcb\xd6@\xd2\xa7\xa1\xc8gK\xd4\x92R\xdf\xb9\x8d\xf7\xd7\x0f;\xac{\xda\x93\xd0\xe7\xbe\xf7\x11\xbaSW_\xbemv\xcf\xdb\x1b\xb0\xec\x9bb\xa2\x86\x03\x1a\x1d\xea\\\xa8\xe9&\x86\xca\xd5)9\xc6t`\xd3B\x0c\x11\xd4e1\xf9Oyd\x0d\xc6\xea\x96\x90s\xd1q\x95\xac\xc4\x07\xb7\xcc\xa7\x04_;\xf7\x96\x8bM\x0c\x8c\x81\x93\xaf:\xd4\x9d\xaeQ\x80\xb9\x90\x7f\xbc\x01r\xd04.\xbc@\x8bM.\x88\xfb\x06\xc9\"\xb2\x00\xdbb\x95\x8b\xc1N\x9f\xa5\xf4\xfal\xca\x90\x1ecd\xe2V\x9f\x86\x8b\x89^~\x00\x99\xa5\xb3t&\x0d\xd1_\x96z\x17RC$\xc0\x05\xc6\xbau\xdc\xe7\x82\xac\xbe^e\xc9]X\\\xaf\xb2\xf3\xeb\xf7\xb9\x8e\xfb\xc3\xfa\xff\xd9b\xf5\xe3\xb0=o\xebe\xd5g\xf7\xfb\xb7\xdd\xa3\x9d\xdd|Z^\x1f\xfc\xadyh\x9e\xd6\xef\xee\xae\xbe]\xefe\xb9vO\xcb\x87\x83\x17`\xdaH\x14[W\xbb\x90\x0b\x1db\xe8}\xech\x1c\x0c;n\xb0PS\xb8\x9f\xaf\x1eS\xea\x89\xd1\xa7!\xf2\xcf*v\xa1\x84i\xa1\xe4%2Q\xf22du\x8ce\x0f\xa6\xd1D\xb9`(\x8f\xac\xc1X\x9d\x08\xb9\x10=\xd7\xf3\x95x\xe3\x96\xb9\x9b\xe3K\xef^\x0bq\x1d\x03c\xe0\xf97\x1d\x9a^7(\xc0\x9c\xc8\x7f\xde\x009iZ\x17~C\xc2\xb6\x10\xc4c\x8bd\x11Y\x80MX\x17b\xb2\xd3g)\xbd>\x98*d\xfb\x18\x998\xe9n\x1aL\xf4\xf2\x0d\xc8E\x96g\xb94D\xef,\xf3.d\x86H\x80\x0b\x8cMr<\x16\x82\xac\xbe\\-\xe6way\xb9Z\x1c^~^\xe8\xb8\xdd\xad?\xe6\xcb\xd5\xaf\xdd\xe6\xb0i\xae\xeaqq\xbf}}\xfan\xf3\x9bOW\x97;\x7fk\x1e\xda\xc7\xf5\xe0\xee\x9a\xdb\xf5VVk\xf7x\xf5\xb0\xf3\x02L\x8aD1\xb9\xc6\x85B\xe8\x10\xc3\xe8cO\xc7b\xd8q\x8b\xe5\x97\x18\xfd\x99\x92\xa7a\xa6\xe4\xa9N\xb5\x8f\xd5\x08\xa6\xd5D\x85`<\xf0\xdc``L\xc7E{\xf1\xef\xc6\x8f\xf3\xa5(\xd7\x9b{HH]\x0c\x840``\x18\\[\x9d)i/\xa6xW>Y|\x0f\xd4)z`\x8b0\xad\x0d\x9a`@\x97*\xd0\x01\xfa\xa0\xf7-\x02G\xe8R4H\x04\x8e3%\xbb\x93\xe49\xf6\xe0]c\x19\x06\x1dxJ\x99\x9e8z\xf7\x07\x81\xad#\xa8]\x8b\xb0\x1f\x01+\xc7.4\xa0L\xac\xb0$\xd6\xec\x8c\\\xe6\xb9\x92Gp\x14*9}\xb0\x9c\xfd\x0d\x00\x00\xff\xffPK\x07\x08\x9aK\x03\x07\xac\x01\x00\x00q\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00$\x00\x00\x00blog/sources/global/static/hello.txt\xf2H\xcd\xc9\xc9\xd7Q\xf0TH\xccUHT(I\xad(\xd1/\xc8I\xcc\xccSH\xce\xcf+I\xcd+\xd1\x03\x04\x00\x00\xff\xffPK\x07\x08jf\xb7\xc4'\x00\x00\x00!\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00%\x00\x00\x00blog/sources/global/static/robots.txt\n-N-\xd2MLO\xcd+\xb1R\xd0\xe2r\xcc\xc9\xc9/\xb7R\xd0\x07\x04\x00\x00\xff\xffPK\x07\x08\x00\x9b}\x90\x1c\x00\x00\x00\x16\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00&\x00\x00\x00blog/sources/global/static/sitemap.xml\xa4\x8e?O\x850\x14\xc5w>E\xd3\xdd^pBS\xfa\x06\x13\x13\x17'\x9d\x0d\xe2}\xd0\xa4\x7f\xb0\xf7\"\x8f\x10\xbe\xbb\xa1\x11\xe3\xe0`\xe2\xd4\xd3s\xcf\xc9\xf9\xe9\xd3\xc5;\xf1\x81\x89l\x0c\x8d\xacT)\x05\x86.\xbe\xd9\xd07\xf2\xf9\xe9\xfe\xaa\x96'S\xe8)9B\x16\x17\xef\x025r`\x1eo\x01\xe6yVd\x19};\x92\x8a\xa9\x07\xea\x06\xf4-\xc1\x97 \xa5\xba\x91\xa6\x10B\xec\xfd,v\xedbg\xd6\xf5.\x86\xb3\xeds_\xbd\xb6\x84/Sr\xdb\x06\x1a\xf6\xf3\x11\xed\x866\xf4xN\xf8n|\x0c<\xb8E\xc3\x0f\xef\x88\x8d\xc9\xc6dy1\xa5\xaa5|\xff\xf22\x1c\xd3\x7fgXW\xf1P\xd5\x8fjJN\x8d\x91\x98\xc4\xb6A\xf5_\xb2\xeb\xdf\xc9\xf2C\xc8\xa6\xf8\x0c\x00\x00\xff\xffPK\x07\x08\xb3\x99\x81\xbd\xcf\x00\x00\x00\x8c\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00&\x00\x00\x00blog/sources/global/tmpl/home.mustache\x94\x95\xdf\x8f\xe34\x10\xc7\x9fw\xff\x8a!\xc8O\x90&u\xb6\xcbq\x97\x16\xf5\xf6\xb6\xe2^\x00\xc1\x9dN\x08!\xe4\xc6\xd3\xc4w\x8e\x1d\xd9N\xd3\x12\xf5\x7fG\xb6\x93\xbd]X!\xd8\x87\xc6\x9e\xcc|\xbe\xf3\xc3\xf1\x8e\xe3\xba$@6\xeb\xcb\xa5\xe4\xe2\x08\x95d\xd6\xae\x93\x8f}\xbb\xd7\xceh\x05]Z@\x97\xb6<]\x81\xc3\x93K\x87F8\x04\xa3{\xc5\x91\xc3\xbeN93\x9f\x92\xcd5L\x7f\x8f)\x95\x96>\xf2\x16\xbaS\x9a?\xf2\x01(\x9b\xe5\xec\xc4\x85\xed$;\xa77p\xd0\xca\xa5\xc21)\xaad\xf3\x01e\xa5[\x04\xa7\x81)\xc0\x13k;\x89`\x85\xc32k\x96OX\xdd\x8c\x92\xc88\xb4\xe7\xb4H6\xef\x1aa\x837\x08\x0b\x9d\x1e\xd0\xf8l\xcf\x9e\xb5\xfd\xe9-0\xc5\x81I \xaeA\xb0\xba7\x15B\xa5ypF\xe1\x1a4Piu\x10uo\x98\x13Z\x816\xe0\xb0\xed$sB\xd5\x0b\xb8W\x1f\xf5\xf9\x8b2\xeb\xfe-\x8f\xbd\xaf\xf9W\xdd\x033\x08\x16Q\xa8:\xe8\x95^i#\x943z\xd1\xf6\xd6\xb1\xaa\xc12\x0b\xc6Y\x04\x9f\xa0\xcb\x8c\x8b\xe3\xbc\x9d6\xd7\xd7\xe3\x08_\xde\x9f\x9ca\x0b\x8e\xfb\xbe\x86\xcb\xe5\xfaq\xef\x8d\x1e\x92\xcd\xf5\xd53\xe3XR\xff\xe2j\x1c7\xc0:A\x1b\xd7\xca\xec\x01q5\xf1\xa7\xc78B\xf67\x91\xe7T|.[c\xd89\"\x9e9\x02A\xf2\xc9\x0bf8\x1c$\x9e\xfck\xa3\x07\xdf\xaf\x1b\xd8\xebSj\x1b\xc6\xf5\x00\x8d\x7fAWy\x88\xfcGh\xba\xd7\xfc\x0c<\xf5\x84\x88\xa9\xb4\xec[\x05L\x8aZ\xa5\xc2akS\xeb\x98q1\xfe\xaa\xb4\xfe8\xd7\x0f\xa7.\x15J\n\x85\xe9^\xea\xea\x93\x17\xa7\xf1|wF\xb4\xcc\x9c\x93\xcd\x07m$/\xb3\x1861\x9ab\x8e\x8f\xd3\x0d\xd6\xab\x92\xcd\xd6@\x08\x1f\x044\x06\x0f\xeb$+ \xbc]\xbe\xf8a\xd1\x1b\xb9\xe8\xb4u\x16\xc8&\x1bG\x10\x1c.\x97d3\x8e\xe0\x84\x93\x08\x97K\x99\xb1I%k\x8ai\xf5\xa8\xe8v\x9f.c\x86m\xef\x90'\x9b\x0fF8\x87\xca\x1f\xea\xde\xa2\x81\x00}\xefW\x9e\x15\xa6\x17\x18\xdd\x93\xb6y\x82\xaf\x96\xf5N\x07\xf9\xd0G\x1f\xd1M\xfe\xec?\xa6~\xa7\x95\x13\xaaG0\xc8\xb8P\xf5\x9c\xffg\xedR\xb4\xf5\x13q\xd1\xd6\xa9\x11u\xe3\xe2\xc4|\x0e\xc0S\xa5\x15\x02\xf7\xd3\x0e\xb3H\x803\xc7Rk\xaau\xd2h\xc9\xd1,>\xda\x8c\xe6\xf9\x89\xae\xf2\xef\\\x83-\xae]\xd3\xb7\xfb\x04\x98t\xeb\xe4\x9d_+&$\xfc69\xfd\x9e\x80ug\x89\xebd\x10\xdc5/\x81\xe6ywz\x05\x0dz\xed\x97@W~\x9b@\x90\xf0b/E\xcbj\xcc\xec\xb1\xfe\xea\xd4\xcaWU\xc3\x8cE\xb7~\xffn\x97\xbe\xf8\x9a\x14w\xf6X\x13\x9a\x07\x18)\xde\x10Ji\x9e\x13J \xcd#r2\xae&\xe3\xa9\x95\xcaF[\xe3\\G\x8a-\xa1;Bw\xc30,\x86b\xa1MM\xe8\x8e\xe6\x9e\xb1\x0bh\x1ft\x148\xbc\xd6\xa7\x18\x96\x93\xa0\x90O\xbf3\xb83h\xd1\x1cqk;\xac\xdc\xcf\xfej\x8a\xfe\xbe\x85\xde\xa5\xb8'\xc5\x1d\xc7\x83\x8d\xab\xd0\x05Bsw\xee0:\xfa\xf1\x13\xba\xab\xac\x9d\xdci\x11{\xfc\xc7\xf2vy\x9b\xdf\xe4\xf4\xe6\xc5\xdeG\x04\xbf\x9c|\xf3\x9a\xd0\xfc \xa4\x0cE\x14X!\xe2\x81\x14\xaf\xc3U=L\xc5o\xf7Z\xf2\xd9x`\xad\x90gRl\xb7F0I\xe8\x1d\xa1\xf9\xf7(\x8f\xe8D\xc5\xe2\xf6\xc7\x0e\x15\xa1\xf9/L\xd9h\xb0L\xd9\xd4\xa2\x11\x87\xb8o\xb5\xd2\xb6c\x15\xceP+\xfeDRl\x97E\x17\xb3z\xe3\x7f\x8b;\xdf\xbfPb\xa8\x96\xd0\xdd\xe7\xd2\xfd\xc0\x04\x9f\x86\xf0L\x85S\xaf\x0cV\xee\xff\x8c6\xb6\xe2M\xd8\x14\xab\xd5\xea\xdbU\xf5\xc0\"t\x17p\x93~xL}\x9c\xc6\xba\xba]\xd0\xbcX\xd2U\x84\x9d\xa3uY,#\xe3\xe1$GX\x88\x9d\xc9\xf53+\x7fv\x8a\xfb\xe9{\x89E\xa6\x06\x15\xf7\xff\xeb\xd6\x893=\xc6[w\xfa \xe7\xa7\xbf\xd3\x1f.\xeb\xc9\xf8W\x00\x00\x00\xff\xffPK\x07\x08\xc5\x1c\xcd\xd3\x02\x04\x00\x00\x07\x08\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00-\x00\x00\x00blog/sources/global/tmpl/main_layout.mustache\x94V\xdd\x8e\xdb6\x13\xbdv\x9eb>~\xc8U!\xd1\xf6z\xd3E@ \xd8$\x9bl\x82\xb6q\xbb\x06v{U\xd0\xd4Xb\x96\"U\x92\xb2%\x18~\xae\xde\xf7\xc9\n\xea\xc7\xf5\xa6Y\xb4\xbe\xd2hf\xceh\xce\xf0\x8c\xe9\xfd>a/\xe1e\x9a\x1c\x0e\xec\x7f\xef>\xbf]\xfd\xba\xbc\x81\xc2\x97*}\xc1\xc2\x03\x14\xd7yB\xf6{l\xbc\xe5qx;\x1cH\x08\"\xcf\xd2\x17\x13V\xa2\xe7 \nn\x1d\xfa\x84\xd4~\x13]\x91\xe0WR?\x82E\x95\x10\xe7[\x85\xae@\xf4\x04\n\x8b\x9b\x84\x14\xdeW\xee5\xa5%oD\xa6\xe3\xb51\xdey\xcb\xab\xf0\"LI\x8f\x0e\xba\x88\xa7\xf1\x94\n\xe7\xfe\xf6\xc5\xa5\xd4\xb1p\x8e\x80\xd4\x1es+}\x9b\x10W\xf0\x8b\xabE\xf4A_^\\-\x9a\xdf\x7f\x9eqs\xffp\xfd\xdd\xf4\xf2\xea\x97\x87e\xb3\xcc_m\xda\xc5\xc7\xfb\xed\xea\xa7bz3\x7fu\xf1P\xbe\x17\x9f\xd4\xdd\xf5N~\xc8\xdf_\xdf\xd3\xecZ\xde\xbd\xfa\xf4P\x12\x10\xd68g\xac\xcc\xa5N\x08\xd7F\xb7\xa5\xa9]\xc7\xc8K\xaf0\xbd^~\x9c\xdf\xae~\xfc\x01\xdeai\x18\xed\x9d/\x18\xed\xe7\xc1\xd6&kCr&\xb7 \x14w.!\xc2h\xcf\xa5F\x1b\xaaL\xba\xc1\xa1\x1d\x83ke\xf2hpUmt\xd1\xe5<\x81[\xb3\x83\x8d\xc2&\xd2fgy\x05_j\xe7\xe5\xa6\x8dBY\xd4>Z\xa3\xdf!j\xe0J\xe6:\x92\x1eK\x17 \xd4~\xf8\xde\xe4\xab^T\xb4\x80\xcaG\xb3!8a|\x8cyl|T\xd6\x1e\xb3\xf1\x9c\xfeO\xd2\xbbz\xed\x84\x95kd\x94\x0f\xe5h&\xb7\xcfU\xeej<\xf9\xfa\x84\x15\xb3\xe3\xd0\x18-f\xff\xa1J\x16\x05\xc2\xff`\x8a:{\x9e\xe5\xbf\x11\xe9\x93&\xccmshJ\xa5]/\xc3\xd7\x94\xeev\xbbxw\x11\x1b\x9b\xd3\xf9t:\xa5n\x9b\x13\xd8\xc9\xcc\x17 \x99O \x14(\xf3\xc2\xf7\xf6V\xe2\xee\x8di\x122\x85)\xcc\x170_\x10\xd8H\xa5\x12\xa2\x8dF\x02\xce[\xf3\x88 \x11\xb5\xb5\xa8\xfd[\xa3\x8c\x1d\xbd\xd1X\xf3\xe8PR\xa3\xe0U8\xe3ZgO\xdc_L\x10\xe0\xe0\x1fx\x95M\xd0\x07\x13\xd2\n\x85 \x9a\x84\xcc\xa6\xf1%\x01\xd1\x8e\x96M\xc8\xf7\xf1%I\x19\xed\x93\xd2\xb0\x86\x08\xcd,!\xf3\x19\x81vx6\xf3\x84\xcc.\xe3+\x02\xedh\xa5\x8c\x86\xd4\x94\x05\xfa\xe3D\xc7#?\x99\xed\xdakX{\x1d\xb9\xb2{\x98\xda\x07X\xe4P\x18\x9dq\xdb\x9eJG\xe6\x1a\xea\xea\x1b\xc29Z\xfd\xda\xa0\xed\xec\x13\x19h\xbe\x8d\x9c\xb0F\xa9~-fP\xae\xa3\xf9\xb0\x1b\x9a\x9f\xe6='\x96a-\xc6\x1d8\x12\xa8\xa2\xf9\xd8#%\xe9\xad)O\x94}\x9a\x04\xdfT\xd1\xbd\xb1*;\x07\xb0BQh\xa3L\xde\x9e\x83z\x87N\xe6\xfa\x1c\xc4\xdbZ\xf9\xda\x9e\xc5\xe5M\xed\xa4F\xe7\xce\xc1,\x8d\x92^\x8a\xb30\x9f+\xa9\xa59\x8b\xcd\x9d\x90\xa8\xc5Yln\x91+_\x9c\x83\xb8\x0b\xf7\xd29\x80\x95\xe5[T#\x82Q\xcd\x07\x11wj\x0eV\xc9\xa5\x06k\x14&$\x98\xa4\xf3N\xf6\xfb=\x0c\xb2\x84\xc3\xe1\xd0e\xd2\x10\xef\xd0\x1bc|\xbf\x01\x13V\x8d=l\x94\xe1>\xb2\xe1\x87\x87\xa4\x8c\x9f\x1c\x1a\x17\x8f\xe0\x0dx\xd3-\x16\xa3\xd5\x80LW\x05\x82\xe2\xad\xa9=\x98\x0d\xf8B:\xa8x\x8e \x1d\xd4:\\/L\x98\x0c\xd3\xf0\xdd\xdf\xfa\xbc\xb8\xac\x9d\xe7\xa2@F\xbb\xd0I\xb1\xfd\x1en\xba\x0b_\x98\xaa\xed\xda\x80\xc3\x01\xfe\xfc\x03N{YZ\xb9\xe5\xa2\x13\xf6\xd7\xa1\x15\xda\xd2\x9d4\xc8\xe8\x91\xe60.F\xfb\xab\x92\xd1\xfe\xdf\xc6_\x01\x00\x00\xff\xffPK\x07\x08\x16\xdd-\xcb\x9d\x03\x00\x00\x89\x08\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00&\x00\x00\x00blog/sources/global/tmpl/post.mustache\x9cR\xc1\x8a\xdb0\x14<\xcb_\xf1P\xc9-\x8e\x9a\x04J1\xb2JK/\xe9\xa9\x87B\xcf\xb2\xf5R\xa9k[BR\xb2\x18\xe3\x7f_,;\xde\xb0\x81=\xec\xc9x\x86\xd1\xcc\x9b\xf7\x86\xa1\xe4\x1b\xd8\x88r\x1c\xb92W\xa8\x1b\x19BI\xbd}\xa6\"#\xf7Pm\x9b\xbcU\xf9\x97 '\\\x1fnx\xd5\xd8\x7f\xb9\xb3!\xe6\xd1\xc4\x06\xa9\x18\x06\xf8)\xa3\xdc\xa5_\x18G\xce\xf4!i\xdc\xa3\xa4\xc5(\xa9\xf8%\xbb\x8b\xf4=\xec\xb7p\xf8\xbc\xff\nU\x0f\xd8\xba\xc6\xf6\x88\xd0]\xda\n=p \xda\xe3\xb9\xa4\x9f^\x0d.\x01\xfdI%\x07)8s\"K.+_Y\xd5'\xd6\xcd\x91=\x9bc\x88\xf51F\xc5\x0fY?\xadz\xc2\x992\xd7\xf7\x07?\x8a\xbf\x1a=B\xd4\x08JF \xb5m1\xc0\xd9\xdb\xf6\x1bg\xfa\xb8x\xfc\xd1\x08\x1e\x83\xb3]\xc0D&\xc1\xf7\xdf'0\xa1\xc8\x08!k\x08\x1d\xa3\x0b\x05c\xff\x83\xed\\#k\xd4\xb6Q\xe8w\xb1w\xa6\xb6\nw\xb5m\xd9\xd4V`\xb7\xc9\xcc4u\xcaC\xc8G\xe5)\x03\x93)\xee\xd2\x90\xf3(\xb2!\x03\xa0s\xb5\xb4\x80\x87\xae\xb7\x13m\xee)\xb3\xc2\xf3\x01\x14@\xdf\x9e\x00M\xf4\xb4\x8f{v\xd9\x0f\xcd\xc6\x8c\xb3\xe4\xbd.`\xf9\xbc\x04\x00\x00\xff\xffPK\x07\x08\x93q2&L\x01\x00\x00\x9e\x02\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00T\x04\xa3R\x00\x00\x00_\x00\x00\x00\x1c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x00blog/config/es_ES/config.iniPK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL\xcc\xb8?\xf3K\x00\x00\x00]\x00\x00\x00\x1c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x9c\x00\x00\x00blog/config/es_ES/routes.iniPK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL?\xf2fp\xf5\x00\x00\x00\xa5\x01\x00\x00\x1d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x811\x01\x00\x00blog/config/global/config.iniPK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mLX\xbe\x1dKE\x00\x00\x00W\x00\x00\x00\x1d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81q\x02\x00\x00blog/config/global/routes.iniPK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL\xf9\xfcp\xd3V\x00\x00\x00\\\x00\x00\x00\x13\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x01\x03\x00\x00blog/i18n/en_US.iniPK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL\xd0\xd3 :`\x00\x00\x00i\x00\x00\x00\x13\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x98\x03\x00\x00blog/i18n/es_ES.iniPK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL\xc1\xe6\xa7H\xbe\x01\x00\x00\x81\x02\x00\x00\x1d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x819\x04\x00\x00blog/sources/es_ES/static/404PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL\xc8\xe3\xa8\xf8\xc7\x01\x00\x00\x98\x02\x00\x00\x1d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81B\x06\x00\x00blog/sources/es_ES/static/500PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL}\x1b\xae\xf5\x0f\x04\x00\x00\x11\x08\x00\x00%\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81T\x08\x00\x00blog/sources/es_ES/tmpl/home.mustachePK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mLn\x08\x0b\xc9r\x00\x00\x00\xb3\x00\x00\x00\x1e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xb6\x0c\x00\x00blog/sources/global/DockerfilePK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL\xeb\x1a\x0d\x12\xb0\x01\x00\x00p\x05\x00\x00\x1f\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81t\x0d\x00\x00blog/sources/global/config.jsonPK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL\xe8\xc9\xc2/\xb4\x01\x00\x00z\x02\x00\x00\x1e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81q\x0f\x00\x00blog/sources/global/static/404PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL\x9aK\x03\x07\xac\x01\x00\x00q\x02\x00\x00\x1e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81q\x11\x00\x00blog/sources/global/static/500PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mLjf\xb7\xc4'\x00\x00\x00!\x00\x00\x00$\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81i\x13\x00\x00blog/sources/global/static/hello.txtPK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL\x00\x9b}\x90\x1c\x00\x00\x00\x16\x00\x00\x00%\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xe2\x13\x00\x00blog/sources/global/static/robots.txtPK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL\xb3\x99\x81\xbd\xcf\x00\x00\x00\x8c\x01\x00\x00&\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81Q\x14\x00\x00blog/sources/global/static/sitemap.xmlPK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL\xc5\x1c\xcd\xd3\x02\x04\x00\x00\x07\x08\x00\x00&\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81t\x15\x00\x00blog/sources/global/tmpl/home.mustachePK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL\x16\xdd-\xcb\x9d\x03\x00\x00\x89\x08\x00\x00-\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xca\x19\x00\x00blog/sources/global/tmpl/main_layout.mustachePK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xd8\x96mL\x93q2&L\x01\x00\x00\x9e\x02\x00\x00&\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xc2\x1d\x00\x00blog/sources/global/tmpl/post.mustachePK\x05\x06\x00\x00\x00\x00\x13\x00\x13\x00\xc2\x05\x00\x00b\x1f\x00\x00\x00\x00" 11 | fs.Register(data) 12 | } 13 | --------------------------------------------------------------------------------