├── 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 |
13 | {{ #Array }}
14 | - {{ title }}
15 | {{ /Array }}
16 |
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 |
January 1, 2018 by employee number {{ Data.userId }}
5 |
6 |
{{ Data.body }}
7 |
8 |
Back
9 |
10 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/skeleton/files/blog/sources/global/tmpl/post.mustache:
--------------------------------------------------------------------------------
1 | {{=<% %>=}}
2 |
3 |
{{ Data.title }}
4 |
January 1, 2018 by employee number {{ Data.userId }}
5 |
6 |
{{ Data.body }}
7 |
8 |
Back
9 |
10 |
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 |
26 |
Written by user {{ idUser }}
27 |
{{ body }}
28 |
Continue reading
29 |
30 |
![Thumbnail [200x250]](data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%22200%22%20height%3D%22250%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%20250%22%20preserveAspectRatio%3D%22none%22%3E%3Cdefs%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E%23holder_1616040248b%20text%20%7B%20fill%3A%23eceeef%3Bfont-weight%3Abold%3Bfont-family%3AArial%2C%20Helvetica%2C%20Open%20Sans%2C%20sans-serif%2C%20monospace%3Bfont-size%3A13pt%20%7D%20%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22holder_1616040248b%22%3E%3Crect%20width%3D%22200%22%20height%3D%22250%22%20fill%3D%22%2355595c%22%3E%3C%2Frect%3E%3Cg%3E%3Ctext%20x%3D%2256.203125%22%20y%3D%22131%22%3EThumbnail%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E)
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 |
27 |
Written by user {{ idUser }}
28 |
{{ body }}
29 |
Continue reading
30 |
31 |
![Thumbnail [200x250]](data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%22200%22%20height%3D%22250%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%20250%22%20preserveAspectRatio%3D%22none%22%3E%3Cdefs%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E%23holder_1616040248b%20text%20%7B%20fill%3A%23eceeef%3Bfont-weight%3Abold%3Bfont-family%3AArial%2C%20Helvetica%2C%20Open%20Sans%2C%20sans-serif%2C%20monospace%3Bfont-size%3A13pt%20%7D%20%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22holder_1616040248b%22%3E%3Crect%20width%3D%22200%22%20height%3D%22250%22%20fill%3D%22%2355595c%22%3E%3C%2Frect%3E%3Cg%3E%3Ctext%20x%3D%2256.203125%22%20y%3D%22131%22%3EThumbnail%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E)
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 |
27 |
Escrito por el usuario {{ idUser }}
28 |
{{ body }}
29 |
Continuar leyendo
30 |
31 |
![Thumbnail [200x250]](data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%22200%22%20height%3D%22250%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%20250%22%20preserveAspectRatio%3D%22none%22%3E%3Cdefs%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E%23holder_1616040248b%20text%20%7B%20fill%3A%23eceeef%3Bfont-weight%3Abold%3Bfont-family%3AArial%2C%20Helvetica%2C%20Open%20Sans%2C%20sans-serif%2C%20monospace%3Bfont-size%3A13pt%20%7D%20%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22holder_1616040248b%22%3E%3Crect%20width%3D%22200%22%20height%3D%22250%22%20fill%3D%22%2355595c%22%3E%3C%2Frect%3E%3Cg%3E%3Ctext%20x%3D%2256.203125%22%20y%3D%22131%22%3EThumbnail%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E)
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 |
26 |
42 |
43 |
44 |
45 | {{{ content }}}
46 |
47 |
48 |
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 |
26 |
42 |
43 |
44 |
45 | {{{ content }}}
46 |
47 |
48 |
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 |
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 | 
3 |
4 | [](https://travis-ci.org/devopsfaith/api2html) [](https://goreportcard.com/report/github.com/devopsfaith/api2html) [](https://coveralls.io/github/devopsfaith/api2html?branch=master) [](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 | | {{name}} |
35 | {{price}} |
36 |
37 | {{/data}}
38 |
39 | {{^data}}
40 |
41 | | There are no products in this category |
42 |
43 | {{/data}}
44 |
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 |
--------------------------------------------------------------------------------