kiln/kiln.go

422 lines
9.9 KiB
Go
Raw Normal View History

2020-09-22 23:46:30 +00:00
package main
import (
2020-09-29 14:57:15 +00:00
"bytes"
2020-09-29 18:10:49 +00:00
"fmt"
"html"
2020-09-22 23:46:30 +00:00
"io/ioutil"
"os"
"path/filepath"
"regexp"
2020-09-29 15:22:54 +00:00
"sort"
2020-09-22 23:46:30 +00:00
"strings"
"text/template"
"time"
2020-09-29 18:10:49 +00:00
"git.sr.ht/~adnano/gmi"
2020-09-22 23:46:30 +00:00
)
2020-09-23 17:50:25 +00:00
// Site represents a kiln site.
2020-09-22 23:46:30 +00:00
type Site struct {
2020-09-30 01:30:50 +00:00
Title string // Site title.
URL string // Site URL.
dir *Dir // Site directory.
tmpl *template.Template // Templates.
feeds bool // Whether or not to generate feeds.
2020-09-22 23:46:30 +00:00
}
2020-09-30 01:30:50 +00:00
// load loads the Site from the current directory.
func (s *Site) load() error {
// Load content
const srcDir = "src"
s.dir = NewDir("")
if err := site.dir.Read(srcDir, ""); err != nil {
2020-09-29 20:42:27 +00:00
return err
2020-09-22 23:46:30 +00:00
}
2020-09-30 01:30:50 +00:00
// Load templates
const tmplDir = "templates"
s.tmpl = template.New(tmplDir)
// Add default templates
s.tmpl.AddParseTree("atom.xml", atomTmpl.Tree)
s.tmpl.AddParseTree("index.gmi", indexTmpl.Tree)
s.tmpl.AddParseTree("page.gmi", pageTmpl.Tree)
// Add function to get site
s.tmpl = s.tmpl.Funcs(template.FuncMap{
"site": func() *Site { return s },
})
var err error
s.tmpl, err = s.tmpl.ParseGlob(filepath.Join(tmplDir, "*.gmi"))
if err != nil {
2020-09-29 20:42:27 +00:00
return err
2020-09-22 23:46:30 +00:00
}
2020-09-29 20:42:27 +00:00
return nil
2020-09-22 23:46:30 +00:00
}
2020-09-30 01:30:50 +00:00
// write writes the contents of the Index to the provided destination directory.
func (s *Site) write(dstDir string, format OutputFormat) error {
2020-09-22 23:46:30 +00:00
// Empty the destination directory
if err := os.RemoveAll(dstDir); err != nil {
return err
}
2020-09-23 01:11:56 +00:00
// Create the destination directory
2020-09-22 23:46:30 +00:00
if err := os.MkdirAll(dstDir, 0755); err != nil {
return err
}
2020-09-23 01:11:56 +00:00
// Write the directory
2020-09-30 01:30:50 +00:00
return s.dir.Write(dstDir, format)
2020-09-22 23:46:30 +00:00
}
2020-09-30 01:30:50 +00:00
// manipulate processes and manipulates the site's content.
func (s *Site) manipulate() error {
// FIXME: manipulate doesn't descend into subdirectories
2020-09-23 01:11:56 +00:00
// Write the directory index file, if it doesn't exist
2020-09-30 01:30:50 +00:00
const indexPath = "index.gmi"
if s.dir.index == nil {
2020-09-29 14:57:15 +00:00
var b bytes.Buffer
2020-09-30 01:30:50 +00:00
tmpl := s.tmpl.Lookup(indexPath)
if err := tmpl.Execute(&b, s.dir); err != nil {
2020-09-29 20:42:27 +00:00
return err
}
2020-09-30 01:30:50 +00:00
s.dir.index = &Page{
Permalink: s.dir.Permalink,
content: b.Bytes(),
2020-09-23 01:11:56 +00:00
}
}
// Manipulate pages
2020-09-30 01:30:50 +00:00
const pagePath = "page.gmi"
for i := range s.dir.Pages {
2020-09-29 14:57:15 +00:00
var b bytes.Buffer
2020-09-30 01:30:50 +00:00
tmpl := s.tmpl.Lookup(pagePath)
if err := tmpl.Execute(&b, s.dir.Pages[i]); err != nil {
2020-09-22 23:46:30 +00:00
return err
}
2020-09-30 01:30:50 +00:00
s.dir.Pages[i].content = b.Bytes()
2020-09-22 23:46:30 +00:00
}
return nil
}
2020-09-30 01:30:50 +00:00
// sort sorts the site's pages by date.
func (s *Site) sort() {
s.dir.Sort()
2020-09-29 15:22:54 +00:00
}
2020-09-30 01:30:50 +00:00
// Feed represents a feed.
2020-09-29 20:42:27 +00:00
type Feed struct {
2020-09-30 01:30:50 +00:00
Title string // Feed title.
Path string // Feed path.
URL string // Site URL.
Updated time.Time // Last updated time.
Entries []*Page // Feed entries.
2020-09-29 20:42:27 +00:00
}
2020-09-30 01:30:50 +00:00
// createFeeds creates Atom feeds.
func (s *Site) createFeeds() error {
2020-09-29 20:42:27 +00:00
const atomPath = "atom.xml"
var b bytes.Buffer
2020-09-30 01:30:50 +00:00
tmpl := s.tmpl.Lookup(atomPath)
2020-09-29 20:42:27 +00:00
feed := &Feed{
2020-09-30 01:30:50 +00:00
Title: s.Title,
Path: filepath.Join(s.dir.Permalink, atomPath),
URL: s.URL,
2020-09-29 20:42:27 +00:00
Updated: time.Now(),
2020-09-30 01:30:50 +00:00
Entries: s.dir.Pages,
2020-09-29 20:42:27 +00:00
}
if err := tmpl.Execute(&b, feed); err != nil {
return err
}
2020-09-30 01:30:50 +00:00
path := filepath.Join(s.dir.Permalink, atomPath)
s.dir.Files[path] = b.Bytes()
2020-09-29 20:42:27 +00:00
return nil
}
2020-09-22 23:46:30 +00:00
// Page represents a page.
type Page struct {
2020-09-30 01:30:50 +00:00
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.
2020-09-29 14:57:15 +00:00
}
2020-09-30 01:30:50 +00:00
// Content returns the page content as a string.
// Used in templates.
2020-09-29 14:57:15 +00:00
func (p *Page) Content() string {
return string(p.content)
2020-09-22 23:46:30 +00:00
}
2020-09-30 01:30:50 +00:00
// Regexp to parse title from Gemini files
2020-09-23 01:15:03 +00:00
var titleRE = regexp.MustCompile("^# ?([^#\r\n]+)\r?\n?\r?\n?")
2020-09-22 23:46:30 +00:00
2020-09-23 17:50:25 +00:00
// NewPage returns a new Page with the given path and content.
2020-09-29 14:57:15 +00:00
func NewPage(path string, content []byte) *Page {
2020-09-22 23:46:30 +00:00
// Try to parse the date from the page filename
2020-09-23 01:11:56 +00:00
var date time.Time
2020-09-22 23:46:30 +00:00
const layout = "2006-01-02"
base := filepath.Base(path)
if len(base) >= len(layout) {
dateStr := base[:len(layout)]
2020-09-23 01:11:56 +00:00
if time, err := time.Parse(layout, dateStr); err == nil {
date = time
}
// Remove the date from the path
base = base[len(layout):]
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-09-22 23:46:30 +00:00
}
}
// Try to parse the title from the contents
2020-09-23 01:11:56 +00:00
var title string
2020-09-29 14:57:15 +00:00
if submatches := titleRE.FindSubmatch(content); submatches != nil {
title = string(submatches[1])
2020-09-23 01:11:56 +00:00
// Remove the title from the contents
content = content[len(submatches[0]):]
2020-09-22 23:46:30 +00:00
}
2020-09-29 14:57:15 +00:00
permalink := strings.TrimSuffix(path, ".gmi")
2020-09-23 01:11:56 +00:00
return &Page{
2020-09-29 14:57:15 +00:00
Permalink: "/" + permalink + "/",
2020-09-23 01:11:56 +00:00
Title: title,
Date: date,
2020-09-29 14:57:15 +00:00
content: content,
2020-09-23 01:11:56 +00:00
}
2020-09-22 23:46:30 +00:00
}
2020-09-30 01:30:50 +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).
2020-09-22 23:46:30 +00:00
}
2020-09-30 01:30:50 +00:00
// NewDir returns a new Dir with the given path.
func NewDir(path string) *Dir {
2020-09-22 23:46:30 +00:00
var permalink string
if path == "" {
permalink = "/"
} else {
permalink = "/" + path + "/"
}
2020-09-30 01:30:50 +00:00
return &Dir{
2020-09-22 23:46:30 +00:00
Permalink: permalink,
2020-09-30 01:30:50 +00:00
Files: map[string][]byte{},
2020-09-22 23:46:30 +00:00
}
}
2020-09-23 01:11:56 +00:00
// Read reads from a directory and indexes the files and directories within it.
2020-09-30 01:30:50 +00:00
func (d *Dir) Read(srcDir string, path string) error {
entries, err := ioutil.ReadDir(filepath.Join(srcDir, path))
2020-09-23 01:11:56 +00:00
if err != nil {
return err
}
for _, entry := range entries {
name := entry.Name()
2020-09-30 01:30:50 +00:00
// Ignore names that start with '_'
if strings.HasPrefix(name, "_") {
continue
}
path := filepath.Join(path, name)
2020-09-23 01:11:56 +00:00
if entry.IsDir() {
// Gather directory data
2020-09-30 01:30:50 +00:00
dir := NewDir(path)
if err := dir.Read(srcDir, path); err != nil {
2020-09-29 20:42:27 +00:00
return err
}
2020-09-30 01:30:50 +00:00
d.Dirs = append(d.Dirs, dir)
2020-09-23 01:11:56 +00:00
} else {
2020-09-30 01:30:50 +00:00
srcPath := filepath.Join(srcDir, path)
2020-09-23 01:11:56 +00:00
content, err := ioutil.ReadFile(srcPath)
if err != nil {
return err
}
switch filepath.Ext(name) {
2020-09-30 01:30:50 +00:00
case ".gmi":
2020-09-23 01:11:56 +00:00
// Gather page data
page := NewPage(path, content)
if name == "index.gmi" {
2020-09-30 01:30:50 +00:00
d.index = page
2020-09-23 01:11:56 +00:00
} else {
d.Pages = append(d.Pages, page)
}
default:
// Static file
2020-09-30 01:30:50 +00:00
d.Files[path] = content
2020-09-23 01:11:56 +00:00
}
}
}
return nil
}
2020-09-30 01:30:50 +00:00
// Write writes the Dir to the provided destination path.
func (d *Dir) Write(dstDir string, format OutputFormat) error {
2020-09-23 01:11:56 +00:00
// Create the directory
2020-09-29 18:10:49 +00:00
dirPath := filepath.Join(dstDir, d.Permalink)
2020-09-23 01:11:56 +00:00
if err := os.MkdirAll(dirPath, 0755); err != nil {
return err
}
2020-09-29 20:42:27 +00:00
// Write static files
2020-09-30 01:30:50 +00:00
for path := range d.Files {
2020-09-29 20:42:27 +00:00
dstPath := filepath.Join(dstDir, path)
f, err := os.Create(dstPath)
if err != nil {
return err
}
2020-09-30 01:30:50 +00:00
data := d.Files[path]
2020-09-29 20:42:27 +00:00
if _, err := f.Write(data); err != nil {
return err
}
}
2020-09-23 01:11:56 +00:00
// Write the files
for _, page := range d.Pages {
2020-09-29 14:57:15 +00:00
path, content := format(page)
dstPath := filepath.Join(dstDir, path)
dir := filepath.Dir(dstPath)
os.MkdirAll(dir, 0755)
2020-09-23 01:11:56 +00:00
f, err := os.Create(dstPath)
if err != nil {
return err
}
2020-09-29 14:57:15 +00:00
if _, err := f.Write(content); err != nil {
2020-09-23 01:11:56 +00:00
return err
}
}
// Write the index file
2020-09-30 01:30:50 +00:00
if d.index != nil {
path, content := format(d.index)
2020-09-29 14:57:15 +00:00
dstPath := filepath.Join(dstDir, path)
2020-09-23 01:11:56 +00:00
f, err := os.Create(dstPath)
if err != nil {
return err
}
2020-09-29 14:57:15 +00:00
if _, err := f.Write(content); err != nil {
2020-09-23 01:11:56 +00:00
return err
}
}
// Write subdirectories
2020-09-30 01:30:50 +00:00
for _, dir := range d.Dirs {
2020-09-29 14:57:15 +00:00
dir.Write(dstDir, format)
2020-09-23 01:11:56 +00:00
}
return nil
2020-09-22 23:46:30 +00:00
}
2020-09-29 14:57:15 +00:00
2020-09-29 15:22:54 +00:00
// Sort sorts the directory's pages by date.
2020-09-30 01:30:50 +00:00
func (d *Dir) Sort() {
2020-09-29 15:22:54 +00:00
sort.Slice(d.Pages, func(i, j int) bool {
return d.Pages[i].Date.After(d.Pages[j].Date)
})
// Sort subdirectories
2020-09-30 01:30:50 +00:00
for _, d := range d.Dirs {
2020-09-29 15:22:54 +00:00
d.Sort()
}
}
2020-09-29 14:57:15 +00:00
// OutputFormat represents an output format.
type OutputFormat func(*Page) (path string, content []byte)
func OutputGemini(p *Page) (path string, content []byte) {
const indexPath = "index.gmi"
path = filepath.Join(p.Permalink, indexPath)
content = p.content
return
}
func OutputHTML(p *Page) (path string, content []byte) {
const indexPath = "index.html"
2020-09-29 18:10:49 +00:00
const meta = `<!DOCTYPE html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css">
<title>%s</title>
`
2020-09-29 14:57:15 +00:00
path = filepath.Join(p.Permalink, indexPath)
2020-09-29 18:10:49 +00:00
var b bytes.Buffer
2020-09-29 18:12:40 +00:00
fmt.Fprintf(&b, meta, html.EscapeString(p.Title))
2020-09-29 18:10:49 +00:00
var pre bool
var list bool
2020-09-29 14:57:15 +00:00
r := bytes.NewReader(p.content)
2020-09-29 18:10:49 +00:00
text := gmi.Parse(r)
for _, l := range text {
if _, ok := l.(gmi.LineListItem); ok {
if !list {
list = true
fmt.Fprint(&b, "<ul>\n")
}
} else if list {
list = false
fmt.Fprint(&b, "</ul>\n")
}
switch l.(type) {
case gmi.LineLink:
link := l.(gmi.LineLink)
url := html.EscapeString(link.URL)
name := html.EscapeString(link.Name)
if name == "" {
name = url
}
fmt.Fprintf(&b, "<p><a href='%s'>%s</a></p>\n", url, name)
case gmi.LinePreformattingToggle:
pre = !pre
if pre {
fmt.Fprint(&b, "<pre>\n")
} else {
fmt.Fprint(&b, "</pre>\n")
}
case gmi.LinePreformattedText:
text := string(l.(gmi.LinePreformattedText))
fmt.Fprintf(&b, "%s\n", html.EscapeString(text))
case gmi.LineHeading1:
text := string(l.(gmi.LineHeading1))
fmt.Fprintf(&b, "<h1>%s</h1>\n", html.EscapeString(text))
case gmi.LineHeading2:
text := string(l.(gmi.LineHeading2))
fmt.Fprintf(&b, "<h2>%s</h2>\n", html.EscapeString(text))
case gmi.LineHeading3:
text := string(l.(gmi.LineHeading3))
fmt.Fprintf(&b, "<h3>%s</h3>\n", html.EscapeString(text))
case gmi.LineListItem:
text := string(l.(gmi.LineListItem))
fmt.Fprintf(&b, "<li>%s</li>\n", html.EscapeString(text))
case gmi.LineQuote:
text := string(l.(gmi.LineQuote))
fmt.Fprintf(&b, "<blockquote>%s</blockquote>\n", html.EscapeString(text))
case gmi.LineText:
text := string(l.(gmi.LineText))
if text == "" {
fmt.Fprint(&b, "<br>\n")
} else {
fmt.Fprintf(&b, "<p>%s</p>\n", html.EscapeString(text))
}
}
}
if pre {
fmt.Fprint(&b, "</pre>\n")
}
if list {
fmt.Fprint(&b, "</ul>\n")
}
content = b.Bytes()
2020-09-29 14:57:15 +00:00
return
}