// Command blog renders a YAML file to a little HTML blog. // // Feel free to adapt it and use it in your setup, if you want your own // personal digital writing implement. package main import ( "bytes" "crypto/md5" "crypto/rand" "encoding/hex" "encoding/json" "flag" "fmt" "html/template" "io" "io/ioutil" "net/url" "os" "path" "sort" "strings" "unicode" "github.com/russross/blackfriday" "gopkg.in/yaml.v2" ) // Post is a single post, title, tags and all. // // It contains all information to get rendered by something, as determined by // the `Type`. type Post struct { ID string `yaml:"id"` Title string `yaml:"title"` URL string `yaml:"url"` Content string `yaml:"content"` Tags []string `yaml:"tags"` Date string `yaml:"date"` Type string `yaml:"type"` } // Options are options that can be specified in the YAML file itself or on the // commandline, to control how the output is done. type Options struct { WriteBack bool `yaml:"write_back"` HashIDs bool `yaml:"hash_ids"` Reverse bool `yaml:"reverse"` CSS string `yaml:"css"` NoDefaultStyle bool `yaml:"no_default_style"` Title string `yaml:"title"` After string `yaml:"after"` PostsDir string `yaml:"posts_dir"` // options only in the YAML prefix: Output string `yaml:"output"` // options only for file output title string showTags bool } var flags struct { Options PrintDefaultStyle bool } var dataPath string = "blog.yaml" var defaultStyle = ` article { margin-bottom: 1em; } article header { display: flex; align-items: center; } article h1 { margin: 0; } article header .permalink { margin-left: 0.5em; text-decoration: none; color: #555; visibility: hidden; } article header:hover .permalink { visibility: visible; } article time { margin-left: 0.5em; color: #666; } article img { max-width: 80vw; max-height: 50vh; } .tags { display: flex; flex-wrap: wrap; list-style-type: none; padding: 0; } .tags .tag-link { color: black; margin-right: 0.5em; } article .tags .tag-link:visited { color: #555; } article.does-not-match { display: none; } #tags { color: #555; font-size: smaller; margin-bottom: 1em; } @media(max-width: 25em) { article h1 { margin-right: 0; text-align: center; } article header { flex-direction: column; } article header .permalink { visibility: visible; } article pre { white-space: pre-wrap; } iframe { max-width: 95vw; } } ` func init() { flag.BoolVar(&flags.WriteBack, "write-back", false, "Rewrite the YAML file with the generated ids") flag.BoolVar(&flags.HashIDs, "hash-ids", false, "Use hash-based post ids") flag.BoolVar(&flags.Reverse, "reverse", false, "Reverse the order of the articles in the file") flag.StringVar(&flags.CSS, "css", "", "Use custom `css` styles") flag.BoolVar(&flags.NoDefaultStyle, "no-default-style", false, "Don't use the default styles") flag.BoolVar(&flags.PrintDefaultStyle, "print-default-style", false, "Print the default styles") flag.StringVar(&flags.Title, "title", "A blog", "Custom `title` to use") flag.StringVar(&flags.After, "after", "", "Insert additional `html` at the end of the generated page") flag.StringVar(&flags.PostsDir, "posts-dir", "", "Directory to write per-post html files to") flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage: %s [flags] [ []]\n\n", os.Args[0]) flag.PrintDefaults() } } func main() { flag.Parse() if flags.PrintDefaultStyle { fmt.Print(defaultStyle) os.Exit(0) } if flag.NArg() > 0 { dataPath = flag.Arg(0) } f, err := os.Open(dataPath) if err != nil { exit(err) } defer f.Close() out := os.Stdout if flag.NArg() > 1 { out, err = os.Create(flag.Arg(1)) if err != nil { exit(err) } } data, err := ioutil.ReadAll(f) if err != nil { exit(err) } i := bytes.Index(data, []byte{'\n', '-', '-', '-', '\n'}) if i != -1 && i != 0 { var opts Options err = yaml.Unmarshal(data[0:i+1], &opts) if err != nil { exit(err) } data = data[i+5:] isSet := map[string]bool{} flag.Visit(func(f *flag.Flag) { isSet[f.Name] = true }) flag.VisitAll(func(f *flag.Flag) { _, ok := isSet[f.Name] if ok { return } switch f.Name { case "write-back": flags.WriteBack = opts.WriteBack case "hash-ids": flags.HashIDs = opts.HashIDs case "reverse": flags.Reverse = opts.Reverse case "css": flags.CSS = opts.CSS case "no-default-style": flags.NoDefaultStyle = opts.NoDefaultStyle case "title": if opts.Title != "" { flags.Title = opts.Title } case "after": flags.After = opts.After case "posts-dir": if opts.PostsDir != "" { flags.PostsDir = opts.PostsDir } } }) // write to specified `output` file if none was given as an argument if opts.Output != "" && out == os.Stdout { // write to same directory as the input file if opts.Output is // just the file name if path.Base(opts.Output) == opts.Output && path.Dir(dataPath) != "." { opts.Output = path.Join(path.Dir(dataPath), opts.Output) } out, err = os.Create(opts.Output) if err != nil { exit(err) } } } var posts []Post err = yaml.Unmarshal(data, &posts) if err != nil { exit(err) } for i, post := range posts { if post.ID == "" { posts[i].ID = generateID(post) } } flags.showTags = true writePosts(posts, out, flags.Options) if flags.PostsDir != "" { for _, post := range posts { postPath := path.Join(path.Dir(dataPath), flags.PostsDir, post.ID+".html") f, err := os.Create(postPath) if err != nil { exit(err) } flags.Title = post.Title flags.showTags = false writePosts([]Post{post}, f, flags.Options) fmt.Println("wrote", postPath) } } } func writePosts(posts []Post, out io.WriteCloser, opts Options) { if flags.NoDefaultStyle { defaultStyle = "" } fmt.Fprintf(out, ` %s `, template.HTMLEscapeString(flags.Title), defaultStyle, flags.CSS) if flags.Reverse { l := len(posts) reversePosts := make([]Post, l) for i := 0; i < l; i++ { reversePosts[i] = posts[l-i-1] } posts = reversePosts } tags := map[string]bool{} for _, post := range posts { if post.Tags != nil { for _, tag := range post.Tags { tags[tag] = true } } var err error switch post.Type { case "shell": err = shellTmpl.Execute(out, post) case "link": err = linkTmpl.Execute(out, post) case "image": err = imageTmpl.Execute(out, post) case "song": err = songTmpl.Execute(out, post) case "text": err = textTmpl.Execute(out, post) case "video": u, err := url.Parse(post.URL) if err != nil { exit(fmt.Errorf("invalid video url '%s'", post.URL)) } var provider string switch { case strings.HasSuffix(u.Path, ".mp4") || strings.HasSuffix(u.Path, ".ogv"): provider = "native" case (u.Host == "youtube.com" || u.Host == "www.youtube.com") && u.Query().Get("v") != "": provider = "youtube" post.URL = fmt.Sprintf("https://www.youtube.com/embed/%s", u.Query().Get("v")) case u.Host == "vimeo.com" && getVimeoID(u.Path) != "": provider = "vimeo" post.URL = fmt.Sprintf("https://player.vimeo.com/video/%s", getVimeoID(u.Path)) default: exit(fmt.Errorf("unsupported video url '%s'", post.URL)) } p := struct { Post Provider string }{post, provider} err = videoTmpl.Execute(out, p) default: fmt.Fprintf(os.Stderr, "Error: no output for type '%s'\n", post.Type) os.Exit(1) } if err != nil { exit(err) } } if opts.showTags { sortedTags := []string{} for tag := range tags { sortedTags = append(sortedTags, tag) } sort.Strings(sortedTags) err := tagsTmpl.Execute(out, sortedTags) if err != nil { exit(err) } } fmt.Fprintf(out, ` `) if flags.After != "" { fmt.Fprintf(out, "\n%s\n", flags.After) } fmt.Fprintf(out, "\n\n\n") out.Close() if flags.WriteBack { dataOut, err := yaml.Marshal(posts) if err != nil { exit(err) } ioutil.WriteFile(dataPath, dataOut, 0664) } } var funcs = template.FuncMap{ "permalink": func(post Post) string { if flags.PostsDir == "" { return "#" + post.ID } return path.Join(flags.PostsDir, post.ID+".html") }, "markdown": func(markdown string) template.HTML { return template.HTML(blackfriday.MarkdownCommon([]byte(markdown))) }, "safe_url": func(s string) template.URL { return template.URL(s) }, "json": func(v interface{}) (string, error) { buf, err := json.Marshal(v) return string(buf), err }, } var baseTmpl = template.Must(template.New("base"). Funcs(funcs).Parse(` {{ $options := .Options }} {{ define "title" }}
{{- if .Title }}

{{ .Title }}

{{ end }} {{- if .Date }} {{ end }}
{{ end }} {{ define "tags" }} {{ end }} `)) var shellTmpl = template.Must(baseTmpl.New("shell"). Funcs(funcs).Parse(`

{{ .Title }}

{{- if .Date }} {{ end }}
{{- if .Content }} {{ markdown .Content }} {{- end -}}
{{ template "tags" .Tags }}
`)) var linkTmpl = template.Must(baseTmpl.New("link"). Funcs(funcs).Parse(`

{{ .Title }}

{{- if .Date }} {{ end }}
{{- if .Content }} {{ markdown .Content }} {{- end -}}
{{ template "tags" .Tags }}
`)) var imageTmpl = template.Must(baseTmpl.New("image"). Funcs(funcs).Parse(`
{{- template "title" . }} {{- if .Content }} {{ markdown .Content }} {{- end -}}
{{ template "tags" .Tags }}
`)) var songTmpl = template.Must(baseTmpl.New("song"). Funcs(funcs).Parse(`
{{- template "title" . }} {{- if .Content }} {{ markdown .Content }} {{- end -}}
{{ template "tags" .Tags }}
`)) var textTmpl = template.Must(baseTmpl.New("text"). Funcs(funcs).Parse(`
{{- template "title" . }} {{- if .Content }} {{ markdown .Content }} {{- end -}}
{{ template "tags" .Tags }}
`)) var videoTmpl = template.Must(baseTmpl.New("video").Parse(`
{{- template "title" . }} {{ if (eq .Provider "youtube" "vimeo") -}} {{- else if (eq .Provider "native") -}} {{- end }} {{- if .Content }} {{ markdown .Content }} {{- end -}}
{{ template "tags" .Tags }}
`)) var tagsTmpl = template.Must(baseTmpl.New("tags-list").Parse(`

All tags:

{{ template "tags" . }}
`)) func exit(err error) { fmt.Fprintf(os.Stderr, "Error: %s\n", err) os.Exit(1) } func generateID(p Post) string { if flags.HashIDs { return hashID(p) } return slugID(p) } func hashID(p Post) string { h := md5.New() io.WriteString(h, p.Title) io.WriteString(h, p.Content) io.WriteString(h, p.Type) return hex.EncodeToString(h.Sum(nil)) } func randomID() string { buf := make([]byte, 16) _, err := rand.Read(buf) if err != nil { panic(err) } return hex.EncodeToString(buf) } var usedSlugs = map[string]int{} func slugID(p Post) string { slug := toSlug(p.Title) n, ok := usedSlugs[slug] if ok { n++ } else { n = 1 } usedSlugs[slug] = n if slug != "" && n == 1 { return slug } else if slug == "" { return fmt.Sprintf("%d", n) } return fmt.Sprintf("%s-%d", slug, n) } func toSlug(s string) string { lastChar := ' ' s = strings.Map(func(ch rune) rune { var newChar rune switch { case unicode.IsLetter(ch) || unicode.IsDigit(ch): newChar = unicode.ToLower(ch) default: if lastChar == '-' { return -1 } newChar = '-' } lastChar = newChar return newChar }, s) return strings.Trim(s, "-") } func getVimeoID(p string) string { i := strings.LastIndex(p, "/") if i == -1 || len(p) == i+1 { return "" } return p[i+1:] }