diff --git a/config/setup/markdown.go b/config/setup/markdown.go
index 3f9b967a..bfe96300 100644
--- a/config/setup/markdown.go
+++ b/config/setup/markdown.go
@@ -2,6 +2,8 @@ package setup
import (
"net/http"
+ "path"
+ "path/filepath"
"github.com/mholt/caddy/middleware"
"github.com/mholt/caddy/middleware/markdown"
@@ -33,7 +35,10 @@ func markdownParse(c *Controller) ([]markdown.Config, error) {
for c.Next() {
md := markdown.Config{
- Renderer: blackfriday.HtmlRenderer(0, "", ""),
+ Renderer: blackfriday.HtmlRenderer(0, "", ""),
+ Templates: make(map[string]string),
+ StaticFiles: make(map[string]string),
+ StaticDir: path.Join(c.Root, markdown.StaticDir),
}
// Get the path scope
@@ -61,6 +66,23 @@ func markdownParse(c *Controller) ([]markdown.Config, error) {
return mdconfigs, c.ArgErr()
}
md.Scripts = append(md.Scripts, c.Val())
+ case "template":
+ tArgs := c.RemainingArgs()
+ switch len(tArgs) {
+ case 0:
+ return mdconfigs, c.ArgErr()
+ case 1:
+ if _, ok := md.Templates[markdown.DefaultTemplate]; ok {
+ return mdconfigs, c.Err("only one default template is allowed, use alias.")
+ }
+ fpath := filepath.Clean(c.Root + string(filepath.Separator) + tArgs[0])
+ md.Templates[markdown.DefaultTemplate] = fpath
+ case 2:
+ fpath := filepath.Clean(c.Root + string(filepath.Separator) + tArgs[1])
+ md.Templates[tArgs[0]] = fpath
+ default:
+ return mdconfigs, c.ArgErr()
+ }
default:
return mdconfigs, c.Err("Expected valid markdown configuration property")
}
diff --git a/middleware/markdown/markdown.go b/middleware/markdown/markdown.go
index 70c3f825..7600780c 100644
--- a/middleware/markdown/markdown.go
+++ b/middleware/markdown/markdown.go
@@ -3,11 +3,9 @@
package markdown
import (
- "bytes"
"io/ioutil"
"net/http"
"os"
- "path"
"strings"
"github.com/mholt/caddy/middleware"
@@ -33,6 +31,16 @@ type Markdown struct {
IndexFiles []string
}
+// Helper function to check if a file is an index file
+func (m Markdown) IsIndexFile(file string) bool {
+ for _, f := range m.IndexFiles {
+ if f == file {
+ return true
+ }
+ }
+ return false
+}
+
// Config stores markdown middleware configurations.
type Config struct {
// Markdown renderer
@@ -49,6 +57,15 @@ type Config struct {
// List of JavaScript files to load for each markdown file
Scripts []string
+
+ // Map of registered templates
+ Templates map[string]string
+
+ // Map of request URL to static files generated
+ StaticFiles map[string]string
+
+ // Directory to store static files
+ StaticDir string
}
// ServeHTTP implements the http.Handler interface.
@@ -73,44 +90,37 @@ func (md Markdown) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error
return http.StatusNotFound, nil
}
+ fs, err := f.Stat()
+ if err != nil {
+ return http.StatusNotFound, nil
+ }
+
+ // if static site is generated, attempt to use it
+ if filepath, ok := m.StaticFiles[fpath]; ok {
+ if fs1, err := os.Stat(filepath); err == nil {
+ // if markdown has not been modified
+ // since static page generation,
+ // serve the static page
+ if fs.ModTime().UnixNano() < fs1.ModTime().UnixNano() {
+ if html, err := ioutil.ReadFile(filepath); err == nil {
+ w.Write(html)
+ return http.StatusOK, nil
+ }
+ }
+ }
+ }
+
body, err := ioutil.ReadAll(f)
if err != nil {
return http.StatusInternalServerError, err
}
- content := blackfriday.Markdown(body, m.Renderer, 0)
-
- var scripts, styles string
- for _, style := range m.Styles {
- styles += strings.Replace(cssTemplate, "{{url}}", style, 1) + "\r\n"
- }
- for _, script := range m.Scripts {
- scripts += strings.Replace(jsTemplate, "{{url}}", script, 1) + "\r\n"
+ html, err := md.process(m, fpath, body)
+ if err != nil {
+ return http.StatusInternalServerError, err
}
- // Title is first line (length-limited), otherwise filename
- title := path.Base(fpath)
- newline := bytes.Index(body, []byte("\n"))
- if newline > -1 {
- firstline := body[:newline]
- newTitle := strings.TrimSpace(string(firstline))
- if len(newTitle) > 1 {
- if len(newTitle) > 128 {
- title = newTitle[:128]
- } else {
- title = newTitle
- }
- }
- }
-
- html := htmlTemplate
- html = strings.Replace(html, "{{title}}", title, 1)
- html = strings.Replace(html, "{{css}}", styles, 1)
- html = strings.Replace(html, "{{js}}", scripts, 1)
- html = strings.Replace(html, "{{body}}", string(content), 1)
-
- w.Write([]byte(html))
-
+ w.Write(html)
return http.StatusOK, nil
}
}
@@ -119,20 +129,3 @@ func (md Markdown) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error
// Didn't qualify to serve as markdown; pass-thru
return md.Next.ServeHTTP(w, r)
}
-
-const (
- htmlTemplate = `
-
-
- {{title}}
-
- {{css}}
- {{js}}
-
-
- {{body}}
-
-`
- cssTemplate = ``
- jsTemplate = ``
-)
diff --git a/middleware/markdown/metadata.go b/middleware/markdown/metadata.go
new file mode 100644
index 00000000..11b7f32a
--- /dev/null
+++ b/middleware/markdown/metadata.go
@@ -0,0 +1,239 @@
+package markdown
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "fmt"
+
+ "github.com/BurntSushi/toml"
+ "gopkg.in/yaml.v2"
+)
+
+var (
+ parsers = []MetadataParser{
+ &JSONMetadataParser{},
+ &TOMLMetadataParser{},
+ &YAMLMetadataParser{},
+ }
+)
+
+// Metadata stores a page's metadata
+type Metadata struct {
+ // Page title
+ Title string
+
+ // Page template
+ Template string
+
+ // Variables to be used with Template
+ Variables map[string]interface{}
+}
+
+// load loads parsed values in parsedMap into Metadata
+func (m *Metadata) load(parsedMap map[string]interface{}) {
+ if template, ok := parsedMap["title"]; ok {
+ m.Title, _ = template.(string)
+ }
+ if template, ok := parsedMap["template"]; ok {
+ m.Template, _ = template.(string)
+ }
+ if variables, ok := parsedMap["variables"]; ok {
+ m.Variables, _ = variables.(map[string]interface{})
+ }
+}
+
+// MetadataParser is a an interface that must be satisfied by each parser
+type MetadataParser interface {
+ // Opening identifier
+ Opening() []byte
+
+ // Closing identifier
+ Closing() []byte
+
+ // Parse the metadata.
+ // Returns the remaining page contents (Markdown)
+ // after extracting metadata
+ Parse([]byte) ([]byte, error)
+
+ // Parsed metadata.
+ // Should be called after a call to Parse returns no error
+ Metadata() Metadata
+}
+
+// JSONMetadataParser is the MetdataParser for JSON
+type JSONMetadataParser struct {
+ metadata Metadata
+}
+
+// Parse the metadata
+func (j *JSONMetadataParser) Parse(b []byte) ([]byte, error) {
+ m := make(map[string]interface{})
+
+ // Read the preceding JSON object
+ decoder := json.NewDecoder(bytes.NewReader(b))
+ if err := decoder.Decode(&m); err != nil {
+ return b, err
+ }
+
+ j.metadata.load(m)
+
+ // Retrieve remaining bytes after decoding
+ buf := make([]byte, len(b))
+ n, err := decoder.Buffered().Read(buf)
+ if err != nil {
+ return b, err
+ }
+
+ return buf[:n], nil
+}
+
+// Parsed metadata.
+// Should be called after a call to Parse returns no error
+func (j *JSONMetadataParser) Metadata() Metadata {
+ return j.metadata
+}
+
+// Opening returns the opening identifier JSON metadata
+func (j *JSONMetadataParser) Opening() []byte {
+ return []byte("{")
+}
+
+// Closing returns the closing identifier JSON metadata
+func (j *JSONMetadataParser) Closing() []byte {
+ return []byte("}")
+}
+
+// TOMLMetadataParser is the MetadataParser for TOML
+type TOMLMetadataParser struct {
+ metadata Metadata
+}
+
+// Parse the metadata
+func (t *TOMLMetadataParser) Parse(b []byte) ([]byte, error) {
+ b, markdown, err := extractMetadata(t, b)
+ if err != nil {
+ return markdown, err
+ }
+ m := make(map[string]interface{})
+ if err := toml.Unmarshal(b, &m); err != nil {
+ return markdown, err
+ }
+ t.metadata.load(m)
+ return markdown, nil
+}
+
+// Parsed metadata.
+// Should be called after a call to Parse returns no error
+func (t *TOMLMetadataParser) Metadata() Metadata {
+ return t.metadata
+}
+
+// Opening returns the opening identifier TOML metadata
+func (t *TOMLMetadataParser) Opening() []byte {
+ return []byte("+++")
+}
+
+// Closing returns the closing identifier TOML metadata
+func (t *TOMLMetadataParser) Closing() []byte {
+ return []byte("+++")
+}
+
+// YAMLMetadataParser is the MetadataParser for YAML
+type YAMLMetadataParser struct {
+ metadata Metadata
+}
+
+// Parse the metadata
+func (y *YAMLMetadataParser) Parse(b []byte) ([]byte, error) {
+ b, markdown, err := extractMetadata(y, b)
+ if err != nil {
+ return markdown, err
+ }
+ m := make(map[string]interface{})
+ if err := yaml.Unmarshal(b, &m); err != nil {
+ return markdown, err
+ }
+ y.metadata.load(m)
+ return markdown, nil
+}
+
+// Parsed metadata.
+// Should be called after a call to Parse returns no error
+func (y *YAMLMetadataParser) Metadata() Metadata {
+ return y.metadata
+}
+
+// Opening returns the opening identifier YAML metadata
+func (y *YAMLMetadataParser) Opening() []byte {
+ return []byte("---")
+}
+
+// Closing returns the closing identifier YAML metadata
+func (y *YAMLMetadataParser) Closing() []byte {
+ return []byte("---")
+}
+
+// extractMetadata extracts metadata content from a page.
+// it returns the metadata, the remaining bytes (markdown),
+// and an error if any.
+// Useful for MetadataParser with defined identifiers (YAML, TOML)
+func extractMetadata(parser MetadataParser, b []byte) (metadata []byte, markdown []byte, err error) {
+ b = bytes.TrimSpace(b)
+ reader := bytes.NewBuffer(b)
+ scanner := bufio.NewScanner(reader)
+
+ // Read first line
+ if !scanner.Scan() {
+ // if no line is read,
+ // assume metadata not present
+ return nil, b, nil
+ }
+
+ line := bytes.TrimSpace(scanner.Bytes())
+ if !bytes.Equal(line, parser.Opening()) {
+ return nil, b, fmt.Errorf("Wrong identifier")
+ }
+
+ // buffer for metadata contents
+ buf := bytes.Buffer{}
+
+ // Read remaining lines until closing identifier is found
+ for scanner.Scan() {
+ line := scanner.Bytes()
+
+ // if closing identifier found
+ if bytes.Equal(bytes.TrimSpace(line), parser.Closing()) {
+
+ // get the scanner to return remaining bytes
+ scanner.Split(func(data []byte, atEOF bool) (int, []byte, error) {
+ return len(data), data, nil
+ })
+ // scan the remaining bytes
+ scanner.Scan()
+
+ return buf.Bytes(), scanner.Bytes(), nil
+ }
+ buf.Write(line)
+ buf.WriteString("\r\n")
+ }
+
+ // closing identifier not found
+ return buf.Bytes(), nil, fmt.Errorf("Metadata not closed. '%v' not found", string(parser.Closing()))
+}
+
+// findParser finds the parser using line that contains opening identifier
+func findParser(b []byte) MetadataParser {
+ var line []byte
+ // Read first line
+ if _, err := fmt.Fscanln(bytes.NewReader(b), &line); err != nil {
+ return nil
+ }
+ line = bytes.TrimSpace(line)
+ for _, parser := range parsers {
+ if bytes.Equal(parser.Opening(), line) {
+ return parser
+ }
+ }
+ return nil
+}
diff --git a/middleware/markdown/metadata_test.go b/middleware/markdown/metadata_test.go
new file mode 100644
index 00000000..68659bf4
--- /dev/null
+++ b/middleware/markdown/metadata_test.go
@@ -0,0 +1,165 @@
+package markdown
+
+import (
+ "bytes"
+ "fmt"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+var TOML = [4]string{`
+title = "A title"
+template = "default"
+[variables]
+name = "value"
+`,
+ `+++
+title = "A title"
+template = "default"
+[variables]
+name = "value"
++++
+Page content
+`,
+ `+++
+title = "A title"
+template = "default"
+[variables]
+name = "value"
+ `,
+ `title = "A title" template = "default" [variables] name = "value"`,
+}
+
+var YAML = [4]string{`
+title : A title
+template : default
+variables :
+- name : value
+`,
+ `---
+title : A title
+template : default
+variables :
+- name : value
+---
+Page content
+`,
+ `---
+title : A title
+template : default
+variables :
+- name : value
+`,
+ `title : A title template : default variables : name : value`,
+}
+var JSON = [4]string{`
+ "title" : "A title",
+ "template" : "default",
+ "variables" : {
+ "name" : "value"
+ }
+`,
+ `{
+ "title" : "A title",
+ "template" : "default",
+ "variables" : {
+ "name" : "value"
+ }
+}
+Page content
+`,
+ `
+{
+ "title" : "A title",
+ "template" : "default",
+ "variables" : {
+ "name" : "value"
+ }
+`,
+ `
+{{
+ "title" : "A title",
+ "template" : "default",
+ "variables" : {
+ "name" : "value"
+ }
+}
+ `,
+}
+
+func check(t *testing.T, err error) {
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestParsers(t *testing.T) {
+ expected := Metadata{
+ Title: "A title",
+ Template: "default",
+ Variables: map[string]interface{}{"name": "value"},
+ }
+ compare := func(m Metadata) bool {
+ if m.Title != expected.Title {
+ return false
+ }
+ if m.Template != expected.Template {
+ return false
+ }
+ for k, v := range m.Variables {
+ if v != expected.Variables[k] {
+ return false
+ }
+ }
+ return true
+ }
+
+ data := []struct {
+ parser MetadataParser
+ testData [4]string
+ name string
+ }{
+ {&JSONMetadataParser{}, JSON, "json"},
+ {&YAMLMetadataParser{}, YAML, "yaml"},
+ {&TOMLMetadataParser{}, TOML, "toml"},
+ }
+
+ for _, v := range data {
+ // metadata without identifiers
+ if _, err := v.parser.Parse([]byte(v.testData[0])); err == nil {
+ t.Fatalf("Expected error for invalid metadata for %v", v.name)
+ }
+
+ // metadata with identifiers
+ md, err := v.parser.Parse([]byte(v.testData[1]))
+ check(t, err)
+ if !compare(v.parser.Metadata()) {
+ t.Fatalf("Expected %v, found %v for %v", expected, v.parser.Metadata().Variables, v.name)
+ }
+ if "Page content" != strings.TrimSpace(string(md)) {
+ t.Fatalf("Expected %v, found %v for %v", "Page content", string(md), v.name)
+ }
+
+ var line []byte
+ fmt.Fscanln(bytes.NewReader([]byte(v.testData[1])), &line)
+ if parser := findParser(line); parser == nil {
+ t.Fatalf("Parser must be found for %v", v.name)
+ } else {
+ if reflect.TypeOf(parser) != reflect.TypeOf(v.parser) {
+ t.Fatalf("parsers not equal. %v != %v", reflect.TypeOf(parser), reflect.TypeOf(v.parser))
+ }
+ }
+
+ // metadata without closing identifier
+ if _, err := v.parser.Parse([]byte(v.testData[2])); err == nil {
+ t.Fatalf("Expected error for missing closing identifier for %v", v.name)
+ }
+
+ // invalid metadata
+ if md, err = v.parser.Parse([]byte(v.testData[3])); err == nil {
+ t.Fatalf("Expected error for invalid metadata for %v", v.name)
+ }
+ }
+
+}
diff --git a/middleware/markdown/process.go b/middleware/markdown/process.go
new file mode 100644
index 00000000..ffcd456d
--- /dev/null
+++ b/middleware/markdown/process.go
@@ -0,0 +1,182 @@
+package markdown
+
+import (
+ "bytes"
+ "fmt"
+ "io/ioutil"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "text/template"
+
+ "github.com/russross/blackfriday"
+)
+
+const (
+ DefaultTemplate = "defaultTemplate"
+ StaticDir = ".caddy_static"
+)
+
+// process processes the contents of a page.
+// It parses the metadata (if any) and uses the template (if found)
+func (md Markdown) process(c Config, requestPath string, b []byte) ([]byte, error) {
+ var metadata = Metadata{}
+ var markdown []byte
+ var err error
+
+ // find parser compatible with page contents
+ parser := findParser(b)
+
+ // if found, assume metadata present and parse.
+ if parser != nil {
+ markdown, err = parser.Parse(b)
+ if err != nil {
+ return nil, err
+ }
+ metadata = parser.Metadata()
+ }
+
+ // if template is not specified, check if Default template is set
+ if metadata.Template == "" {
+ if _, ok := c.Templates[DefaultTemplate]; ok {
+ metadata.Template = DefaultTemplate
+ }
+ }
+
+ // if template is set, load it
+ var tmpl []byte
+ if metadata.Template != "" {
+ if t, ok := c.Templates[metadata.Template]; ok {
+ tmpl, err = ioutil.ReadFile(t)
+ }
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // process markdown
+ markdown = blackfriday.Markdown(markdown, c.Renderer, 0)
+
+ // set it as body for template
+ metadata.Variables["markdown"] = string(markdown)
+
+ return md.processTemplate(c, requestPath, tmpl, metadata)
+}
+
+// processTemplate processes a template given a requestPath,
+// template (tmpl) and metadata
+func (md Markdown) processTemplate(c Config, requestPath string, tmpl []byte, metadata Metadata) ([]byte, error) {
+ // if template is not specified,
+ // use the default template
+ if tmpl == nil {
+ tmpl = defaultTemplate(c, metadata, requestPath)
+ }
+
+ // process the template
+ b := &bytes.Buffer{}
+ t, err := template.New("").Parse(string(tmpl))
+ if err != nil {
+ return nil, err
+ }
+ if err = t.Execute(b, metadata.Variables); err != nil {
+ return nil, err
+ }
+
+ // generate static page
+ if err = md.generatePage(c, requestPath, b.Bytes()); err != nil {
+ // if static page generation fails,
+ // nothing fatal, only log the error.
+ log.Println(err)
+ }
+
+ return b.Bytes(), nil
+
+}
+
+// generatePage generates a static html page from the markdown in content.
+func (md Markdown) generatePage(c Config, requestPath string, content []byte) error {
+ // should not happen,
+ // must be set on Markdown init.
+ if c.StaticDir == "" {
+ return fmt.Errorf("Static directory not set")
+ }
+
+ // if static directory is not existing, create it
+ if _, err := os.Stat(c.StaticDir); err != nil {
+ err := os.MkdirAll(c.StaticDir, os.FileMode(0755))
+ if err != nil {
+ return err
+ }
+ }
+
+ filePath := filepath.Join(c.StaticDir, requestPath)
+
+ // If it is index file, use the directory instead
+ if md.IsIndexFile(filepath.Base(requestPath)) {
+ filePath, _ = filepath.Split(filePath)
+ }
+
+ // Create the directory in case it is not existing
+ if err := os.MkdirAll(filePath, os.FileMode(0755)); err != nil {
+ return err
+ }
+
+ // generate index.html file in the directory
+ filePath = filepath.Join(filePath, "index.html")
+ err := ioutil.WriteFile(filePath, content, os.FileMode(0755))
+ if err != nil {
+ return err
+ }
+
+ c.StaticFiles[requestPath] = filePath
+ return nil
+}
+
+// defaultTemplate constructs a default template.
+func defaultTemplate(c Config, metadata Metadata, requestPath string) []byte {
+ var scripts, styles bytes.Buffer
+ for _, style := range c.Styles {
+ styles.WriteString(strings.Replace(cssTemplate, "{{url}}", style, 1))
+ styles.WriteString("\r\n")
+ }
+ for _, script := range c.Scripts {
+ scripts.WriteString(strings.Replace(jsTemplate, "{{url}}", script, 1))
+ scripts.WriteString("\r\n")
+ }
+
+ // Title is first line (length-limited), otherwise filename
+ title := metadata.Title
+ if title == "" {
+ title = filepath.Base(requestPath)
+ if body, _ := metadata.Variables["markdown"].([]byte); len(body) > 128 {
+ title = string(body[:128])
+ } else if len(body) > 0 {
+ title = string(body)
+ }
+ }
+
+ html := []byte(htmlTemplate)
+ html = bytes.Replace(html, []byte("{{title}}"), []byte(title), 1)
+ html = bytes.Replace(html, []byte("{{css}}"), styles.Bytes(), 1)
+ html = bytes.Replace(html, []byte("{{js}}"), scripts.Bytes(), 1)
+
+ return html
+}
+
+const (
+ htmlTemplate = `
+
+
+ {{title}}
+
+ {{css}}
+ {{js}}
+
+
+ {{.markdown}}
+
+`
+ cssTemplate = ``
+ jsTemplate = ``
+)