Skip to content

Commit ced34bb

Browse files
authored
feat: add HTML renderer (#26)
1 parent db614ca commit ced34bb

File tree

12 files changed

+457
-0
lines changed

12 files changed

+457
-0
lines changed

.codacy.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ engines:
88
exclude_paths:
99
- "examples/**"
1010
- ".github/**"
11+
- "testdata/**"
1112
- "**.md"

context.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,28 @@ func (c *Context) ReadXML(out any) error {
146146
return c.kid.xmlSerializer.Read(c.Request(), out)
147147
}
148148

149+
// HTML sends HTML response with the given status code.
150+
//
151+
// tpl must be a relative path to templates root directory.
152+
// Defaults to "templates/".
153+
//
154+
// Returns an error if an error happened during sending response otherwise returns nil.
155+
func (c *Context) HTML(code int, tpl string, data any) error {
156+
c.writeContentType("text/html")
157+
c.response.WriteHeader(code)
158+
return c.kid.htmlRenderer.RenderHTML(c.Response(), tpl, data)
159+
}
160+
161+
// HTMLString sends bare string as HTML response with the given status code.
162+
//
163+
// Returns an error if an error happened during sending response otherwise returns nil.
164+
func (c *Context) HTMLString(code int, tpl string) error {
165+
c.writeContentType("text/html")
166+
c.response.WriteHeader(code)
167+
_, err := c.Response().Write([]byte(tpl))
168+
return err
169+
}
170+
149171
// NoContent returns an empty response with the given status code.
150172
func (c *Context) NoContent(code int) {
151173
c.response.WriteHeader(code)

context_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"testing"
1111

1212
"github.com/mojixcoder/kid/errors"
13+
htmlrenderer "github.com/mojixcoder/kid/html_renderer"
1314
"github.com/stretchr/testify/assert"
1415
)
1516

@@ -405,3 +406,37 @@ func TestContextXMLByte(t *testing.T) {
405406
assert.Equal(t, "application/xml", res.Header().Get("Content-Type"))
406407
assert.Equal(t, "<person><name>foo</name><age>1999</age></person>", res.Body.String())
407408
}
409+
410+
func TestContextHTML(t *testing.T) {
411+
k := New()
412+
renderer := htmlrenderer.New("testdata/templates/", "layouts/", ".html", false)
413+
renderer.AddFunc("greet", func() int { return 1 })
414+
k.htmlRenderer = renderer
415+
416+
ctx := newContext(k)
417+
418+
res := httptest.NewRecorder()
419+
ctx.reset(nil, res)
420+
421+
err := ctx.HTML(http.StatusAccepted, "index.html", nil)
422+
423+
assert.NoError(t, err)
424+
assert.Equal(t, http.StatusAccepted, res.Code)
425+
assert.Equal(t, "\n<html><body>\n<p>content</p>\n</body></html>\n", res.Body.String())
426+
assert.Equal(t, "text/html", res.Header().Get("Content-Type"))
427+
}
428+
429+
func TestContextHTMLString(t *testing.T) {
430+
ctx := newContext(New())
431+
432+
res := httptest.NewRecorder()
433+
ctx.reset(nil, res)
434+
435+
err := ctx.HTMLString(http.StatusAccepted, "<p>Hello</p>")
436+
437+
assert.NoError(t, err)
438+
assert.Equal(t, http.StatusAccepted, res.Code)
439+
assert.Equal(t, "<p>Hello</p>", res.Body.String())
440+
assert.Equal(t, "text/html", res.Header().Get("Content-Type"))
441+
442+
}

html_renderer/html.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package htmlrenderer
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"html/template"
7+
"io/fs"
8+
"net/http"
9+
"path/filepath"
10+
"strings"
11+
12+
kiderrors "github.com/mojixcoder/kid/errors"
13+
)
14+
15+
var (
16+
// Default root directory.
17+
DefaultRootDir = filepath.FromSlash("templates/")
18+
19+
// Default layout directory. Relative to root directory.
20+
DefaultLayoutsDir = filepath.FromSlash("layouts/")
21+
22+
// Default template file extensions.
23+
DefaultExtension = ".html"
24+
25+
// ErrTemplateNotFound is the internal error when template is not found.
26+
ErrTemplateNotFound = errors.New("template not found")
27+
)
28+
29+
// defaultHTMLRenderer is the default implementation of HTMLRenderer.
30+
type defaultHTMLRenderer struct {
31+
templates map[string]*template.Template
32+
funcMap template.FuncMap
33+
rootDir string
34+
layoutDir string
35+
extension string
36+
debug bool
37+
isInitialized bool
38+
}
39+
40+
// Verifying interface compliance.
41+
var _ HTMLRenderer = (*defaultHTMLRenderer)(nil)
42+
43+
// New returns a new HTML renderer.
44+
func New(templatesDir, layoutsDir, extension string, debug bool) *defaultHTMLRenderer {
45+
htmlRenderer := defaultHTMLRenderer{
46+
rootDir: templatesDir,
47+
layoutDir: layoutsDir,
48+
extension: extension,
49+
debug: debug,
50+
templates: make(map[string]*template.Template),
51+
funcMap: make(template.FuncMap),
52+
}
53+
return &htmlRenderer
54+
}
55+
56+
// Default returns a new default HTML renderer.
57+
func Default(debug bool) *defaultHTMLRenderer {
58+
return New(
59+
DefaultRootDir,
60+
DefaultLayoutsDir,
61+
DefaultExtension,
62+
debug,
63+
)
64+
}
65+
66+
// AddFunc adds a function to its func map.
67+
func (r *defaultHTMLRenderer) AddFunc(name string, f any) {
68+
if f == nil {
69+
panic("function cannot be nil")
70+
}
71+
r.funcMap[name] = f
72+
}
73+
74+
// RenderHTML implements Kid's HTML renderer.
75+
func (r *defaultHTMLRenderer) RenderHTML(res http.ResponseWriter, path string, data any) error {
76+
if err := r.loadTemplates(); err != nil {
77+
return newInternalServerHTTPError(err, err.Error())
78+
}
79+
80+
if tpl, ok := r.templates[path]; !ok {
81+
return newInternalServerHTTPError(ErrTemplateNotFound, fmt.Sprintf("template %s not found", path))
82+
} else {
83+
if err := tpl.Execute(res, data); err != nil {
84+
return newInternalServerHTTPError(err, err.Error())
85+
}
86+
return nil
87+
}
88+
}
89+
90+
// getTemplateAndLayoutFiles returns template and layout files.
91+
func (r *defaultHTMLRenderer) getTemplateAndLayoutFiles() ([]string, []string, error) {
92+
templateFiles := make([]string, 0)
93+
layoutFiles := make([]string, 0)
94+
95+
err := filepath.Walk(r.rootDir, func(path string, info fs.FileInfo, err error) error {
96+
if err != nil {
97+
return err
98+
}
99+
100+
if info.IsDir() || !r.isValidExt(path) {
101+
return nil
102+
}
103+
104+
if r.isLayout(path) {
105+
layoutFiles = append(layoutFiles, path)
106+
} else {
107+
templateFiles = append(templateFiles, path)
108+
}
109+
110+
return nil
111+
})
112+
113+
if err != nil {
114+
return nil, nil, err
115+
}
116+
117+
return templateFiles, layoutFiles, nil
118+
}
119+
120+
// loadTemplates loads and parses templates.
121+
func (r *defaultHTMLRenderer) loadTemplates() error {
122+
if r.shouldntLoadTemplates() {
123+
return nil
124+
}
125+
126+
templateFiles, layoutFiles, err := r.getTemplateAndLayoutFiles()
127+
if err != nil {
128+
return newInternalServerHTTPError(err, err.Error())
129+
}
130+
131+
fmt.Println(r.funcMap)
132+
133+
for _, templateFile := range templateFiles {
134+
name := r.getTemplateName(templateFile)
135+
files := getFilesToParse(templateFile, layoutFiles)
136+
tpl := template.Must(template.New(filepath.Base(name)).Funcs(r.funcMap).ParseFiles(files...))
137+
r.templates[name] = tpl
138+
}
139+
140+
r.isInitialized = true
141+
142+
return nil
143+
}
144+
145+
// shouldntLoadTemplates if true don't need to load templates otherwise load templates.
146+
func (r *defaultHTMLRenderer) shouldntLoadTemplates() bool {
147+
return r.isInitialized && !r.debug
148+
}
149+
150+
// isLayout determines if the file is a layout file or not.
151+
func (r *defaultHTMLRenderer) isLayout(file string) bool {
152+
return strings.HasPrefix(file, r.rootDir+r.layoutDir)
153+
}
154+
155+
// isValidExt determines if the file has valid extension.
156+
func (r *defaultHTMLRenderer) isValidExt(file string) bool {
157+
return strings.HasSuffix(file, r.extension)
158+
}
159+
160+
// getTemplateName extracts template name from file path.
161+
func (r *defaultHTMLRenderer) getTemplateName(filePath string) string {
162+
return filePath[len(r.rootDir):]
163+
}
164+
165+
// getFilesToParse merges template path and layouts into a string slice.
166+
func getFilesToParse(templatePath string, layouts []string) []string {
167+
files := make([]string, 0, len(layouts)+1)
168+
files = append(files, templatePath)
169+
files = append(files, layouts...)
170+
return files
171+
}
172+
173+
// newInternalServerHTTPError returns a new HTTP error.
174+
func newInternalServerHTTPError(err error, msg any) error {
175+
return kiderrors.NewHTTPError(http.StatusInternalServerError).WithError(err).WithMessage(msg)
176+
}

0 commit comments

Comments
 (0)