kiln/main.go

499 lines
11 KiB
Go
Raw Normal View History

2020-09-22 20:42:14 +00:00
package main
import (
2020-10-01 01:57:59 +00:00
"bytes"
2020-09-28 23:54:48 +00:00
"flag"
"fmt"
2020-10-01 01:57:59 +00:00
"io/ioutil"
2020-09-22 20:42:14 +00:00
"log"
2020-10-01 01:57:59 +00:00
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
2020-10-01 01:57:59 +00:00
"strings"
"text/template"
2020-09-28 23:54:48 +00:00
"time"
2020-10-24 21:11:30 +00:00
"git.sr.ht/~adnano/go-gemini"
"git.sr.ht/~adnano/go-ini"
2020-09-28 23:54:48 +00:00
)
2020-10-01 01:57:59 +00:00
const (
2020-10-01 02:14:10 +00:00
srcDir = "src" // site source
dstDir = "dst" // site destination
htmlDst = "dst.html" // html destination
indexPath = "index.gmi" // path used for index files
atomPath = "atom.xml" // path used for atom feeds
tmplDir = "templates" // templates directory
atomTmpl = "/atom.xml" // path to atom template
indexTmpl = "/index.gmi" // path to index template
pageTmpl = "/page.gmi" // path to page template
htmlTmpl = "/output.html" // path to html template
2020-09-22 20:42:14 +00:00
)
2020-10-01 01:57:59 +00:00
// site config
var cfg struct {
title string // site title
url string // site URL
toAtom bool // output Atom
toHTML bool // output HTML
2020-09-28 23:54:48 +00:00
}
2020-09-22 20:42:14 +00:00
func main() {
// Try to read config file
if f, err := os.Open("config.ini"); err == nil {
ini.Parse(f, func(section, key, value string) {
if section == "" {
switch key {
case "title":
cfg.title = value
case "url":
cfg.url = value
case "atom":
b, err := strconv.ParseBool(value)
if err != nil {
fmt.Println(err)
break
}
cfg.toAtom = b
case "html":
b, err := strconv.ParseBool(value)
if err != nil {
fmt.Println(err)
break
}
cfg.toHTML = b
case "serve":
b, err := strconv.ParseBool(value)
if err != nil {
fmt.Println(err)
break
}
}
}
})
}
2020-09-28 23:54:48 +00:00
flag.Parse()
2020-10-01 01:57:59 +00:00
if err := run(); err != nil {
2020-09-22 20:42:14 +00:00
log.Fatal(err)
}
}
2020-10-01 01:57:59 +00:00
// site metadata passed to templates
type site struct {
Title string
URL string
}
// map of paths to templates
var templates = map[string]*template.Template{}
// template functions
var funcMap = template.FuncMap{
"site": func() site {
return site{
Title: cfg.title,
URL: cfg.url,
}
},
}
// loadTemplate loads a template from the provided path and content.
func loadTemplate(path string, content string) {
tmpl := template.New(path)
tmpl.Funcs(funcMap)
template.Must(tmpl.Parse(content))
templates[path] = tmpl
}
2020-10-01 02:14:10 +00:00
// findTemplate searches recursively for a template for the given path.
func findTemplate(path string, tmpl string) *template.Template {
for {
tmplPath := filepath.Join(path, tmpl)
if t, ok := templates[tmplPath]; ok {
return t
}
slash := path == "/"
path = filepath.Dir(path)
if slash && path == "/" {
break
}
}
// shouldn't happen
return nil
}
2020-10-01 01:57:59 +00:00
func run() error {
// Load default templates
loadTemplate(atomTmpl, atom_xml)
loadTemplate(indexTmpl, index_gmi)
loadTemplate(pageTmpl, page_gmi)
loadTemplate(htmlTmpl, output_html)
// Load templates
filepath.Walk(tmplDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Mode().IsRegular() {
b, err := ioutil.ReadFile(path)
if err != nil {
return err
}
// Remove "templates/" from beginning of path
path = strings.TrimPrefix(path, tmplDir)
loadTemplate(path, string(b))
}
return nil
})
// Load content
dir := NewDir("")
if err := dir.read(srcDir, ""); err != nil {
2020-09-22 20:42:14 +00:00
return err
}
2020-10-01 01:57:59 +00:00
// Sort content
dir.sort()
// Manipulate content
if err := manipulate(dir); err != nil {
2020-09-22 20:42:14 +00:00
return err
}
2020-10-01 01:57:59 +00:00
if cfg.toAtom {
if err := createFeeds(dir); err != nil {
2020-09-29 20:42:27 +00:00
return err
}
}
2020-10-01 01:57:59 +00:00
// Write content
if err := write(dir, dstDir, outputGemini); err != nil {
2020-09-22 20:42:14 +00:00
return err
}
2020-10-01 01:57:59 +00:00
if cfg.toHTML {
if err := write(dir, htmlDst, outputHTML); err != nil {
2020-09-29 14:57:15 +00:00
return err
}
}
2020-09-22 20:42:14 +00:00
return nil
}
2020-09-28 23:54:48 +00:00
2020-10-01 01:57:59 +00:00
// write writes the contents of the Index to the provided destination directory.
func write(dir *Dir, dstDir string, format outputFormat) error {
// Empty the destination directory
if err := os.RemoveAll(dstDir); err != nil {
return err
}
// Create the destination directory
if err := os.MkdirAll(dstDir, 0755); err != nil {
return err
}
// Write the directory
return dir.write(dstDir, format)
}
// manipulate processes and manipulates the site's content.
func manipulate(dir *Dir) error {
// Write the directory index file, if it doesn't exist
if dir.index == nil {
var b bytes.Buffer
2020-10-01 02:14:10 +00:00
tmpl := findTemplate(dir.Permalink, indexTmpl)
2020-10-01 01:57:59 +00:00
if err := tmpl.Execute(&b, dir); err != nil {
return err
}
dir.index = &Page{
Permalink: dir.Permalink,
content: b.Bytes(),
}
}
// Manipulate pages
for i := range dir.Pages {
var b bytes.Buffer
2020-10-01 02:14:10 +00:00
tmpl := findTemplate(dir.Pages[i].Permalink, pageTmpl)
2020-10-01 01:57:59 +00:00
if err := tmpl.Execute(&b, dir.Pages[i]); err != nil {
return err
}
dir.Pages[i].content = b.Bytes()
}
// Manipulate subdirectories
for _, d := range dir.Dirs {
if err := manipulate(d); err != nil {
return err
}
}
return nil
}
// feed represents an Atom feed.
type feed struct {
Title string // Feed title.
Path string // Feed path.
URL string // Site URL.
Updated time.Time // Last updated time.
Entries []*Page // Feed entries.
}
// createFeeds creates Atom feeds.
func createFeeds(dir *Dir) error {
var b bytes.Buffer
tmpl := templates[atomTmpl]
feed := &feed{
Title: cfg.title,
Path: filepath.Join(dir.Permalink, atomPath),
URL: cfg.url,
Updated: time.Now(),
Entries: dir.Pages,
}
if err := tmpl.Execute(&b, feed); err != nil {
return err
}
path := filepath.Join(dir.Permalink, atomPath)
dir.Files[path] = b.Bytes()
return nil
}
// Page represents a page.
type Page struct {
Title string // The title of this page.
Permalink string // The permalink to this page.
Date time.Time // The date of the page.
content []byte // The content of this page.
}
// Content returns the page content as a string.
// Used in templates.
func (p *Page) Content() string {
return string(p.content)
}
// Regexp to parse title from Gemini files
var titleRE = regexp.MustCompile("^# ?([^#\r\n]+)\r?\n?\r?\n?")
// Regexp to parse frontmatter from Gemini files
var frontmatterRE = regexp.MustCompile("---\r?\n(?s)(.*)(?-s)---\r?\n?")
2020-10-01 01:57:59 +00:00
// NewPage returns a new Page with the given path and content.
func NewPage(path string, content []byte) *Page {
var page Page
// Try to parse frontmatter
if submatches := frontmatterRE.FindSubmatch(content); submatches != nil {
ini.Parse(bytes.NewReader(submatches[1]), func(section, key, value string) {
if section == "" {
switch key {
case "title":
page.Title = value
case "date":
date, err := time.Parse("2006-01-02", value)
if err != nil {
fmt.Println(err)
break
}
page.Date = date
}
}
// Preserve unrecognized keys
})
fmt.Println(string(submatches[1]))
content = content[len(submatches[0]):]
} else {
// Try to parse the date from the page filename
const layout = "2006-01-02"
base := filepath.Base(path)
if len(base) >= len(layout) {
dateStr := base[:len(layout)]
if time, err := time.Parse(layout, dateStr); err == nil {
page.Date = time
2020-10-01 01:57:59 +00:00
}
// Remove the date from the path
base = base[len(layout):]
2020-10-01 01:57:59 +00:00
if len(base) > 0 {
// Remove a leading dash
if base[0] == '-' {
base = base[1:]
}
if len(base) > 0 {
dir := filepath.Dir(path)
if dir == "." {
dir = ""
}
path = filepath.Join(dir, base)
2020-10-01 01:57:59 +00:00
}
}
}
// Try to parse the title from the contents
if submatches := titleRE.FindSubmatch(content); submatches != nil {
page.Title = string(submatches[1])
// Remove the title from the contents
content = content[len(submatches[0]):]
}
2020-10-01 01:57:59 +00:00
}
page.Permalink = "/" + strings.TrimSuffix(path, ".gmi") + "/"
page.content = content
return &page
2020-10-01 01:57:59 +00:00
}
// Dir represents a directory.
type Dir struct {
Permalink string // Permalink to this directory.
Pages []*Page // Pages in this directory.
Dirs []*Dir // Subdirectories.
Files map[string][]byte // Static files.
index *Page // The index file (index.gmi).
}
// NewDir returns a new Dir with the given path.
func NewDir(path string) *Dir {
var permalink string
if path == "" {
permalink = "/"
} else {
permalink = "/" + path + "/"
}
return &Dir{
Permalink: permalink,
Files: map[string][]byte{},
}
}
// read reads from a directory and indexes the files and directories within it.
func (d *Dir) read(srcDir string, path string) error {
entries, err := ioutil.ReadDir(filepath.Join(srcDir, path))
if err != nil {
return err
}
for _, entry := range entries {
name := entry.Name()
// Ignore names that start with "_"
if strings.HasPrefix(name, "_") {
continue
}
path := filepath.Join(path, name)
if entry.IsDir() {
// Gather directory data
dir := NewDir(path)
if err := dir.read(srcDir, path); err != nil {
return err
}
d.Dirs = append(d.Dirs, dir)
} else {
srcPath := filepath.Join(srcDir, path)
content, err := ioutil.ReadFile(srcPath)
if err != nil {
return err
}
if filepath.Ext(name) == ".gmi" {
// Gather page data
page := NewPage(path, content)
if name == "index.gmi" {
d.index = page
} else {
d.Pages = append(d.Pages, page)
}
} else {
// Static file
d.Files[path] = content
}
}
}
return nil
}
// write writes the Dir to the provided destination path.
func (d *Dir) write(dstDir string, format outputFormat) error {
// Create the directory
dirPath := filepath.Join(dstDir, d.Permalink)
if err := os.MkdirAll(dirPath, 0755); err != nil {
return err
}
// Write static files
for path := range d.Files {
dstPath := filepath.Join(dstDir, path)
f, err := os.Create(dstPath)
if err != nil {
return err
}
data := d.Files[path]
if _, err := f.Write(data); err != nil {
return err
}
}
// Write the pages
for _, page := range d.Pages {
path, content := format(page)
dstPath := filepath.Join(dstDir, path)
dir := filepath.Dir(dstPath)
os.MkdirAll(dir, 0755)
f, err := os.Create(dstPath)
if err != nil {
return err
}
if _, err := f.Write(content); err != nil {
return err
}
}
// Write the index file
if d.index != nil {
path, content := format(d.index)
dstPath := filepath.Join(dstDir, path)
f, err := os.Create(dstPath)
if err != nil {
return err
}
if _, err := f.Write(content); err != nil {
return err
}
}
// Write subdirectories
for _, dir := range d.Dirs {
dir.write(dstDir, format)
}
return nil
}
// sort sorts the directory's pages by date.
func (d *Dir) sort() {
sort.Slice(d.Pages, func(i, j int) bool {
return d.Pages[i].Date.After(d.Pages[j].Date)
})
// Sort subdirectories
for _, d := range d.Dirs {
d.sort()
}
}
// outputFormat represents an output format.
type outputFormat func(*Page) (path string, content []byte)
func outputGemini(p *Page) (path string, content []byte) {
path = filepath.Join(p.Permalink, indexPath)
content = p.content
return
}
func outputHTML(p *Page) (path string, content []byte) {
const indexPath = "index.html"
path = filepath.Join(p.Permalink, indexPath)
r := bytes.NewReader(p.content)
2020-11-01 04:54:07 +00:00
text := gemini.ParseText(r)
2020-10-26 19:18:26 +00:00
content = []byte(textToHTML(text))
2020-10-01 01:57:59 +00:00
type tmplCtx struct {
Title string
Content string
}
var b bytes.Buffer
tmpl := templates[htmlTmpl]
tmpl.Execute(&b, &tmplCtx{
Title: p.Title,
Content: string(content),
})
content = b.Bytes()
return
}