mirror of
https://git.sr.ht/~adnano/kiln
synced 2024-10-30 01:13:08 +00:00
461 lines
10 KiB
Go
461 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"flag"
|
|
"io/ioutil"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
"git.sr.ht/~adnano/go-gemini"
|
|
)
|
|
|
|
const (
|
|
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
|
|
)
|
|
|
|
// site config
|
|
var cfg struct {
|
|
title string // site title
|
|
url string // site URL
|
|
toAtom bool // output Atom
|
|
toHTML bool // output HTML
|
|
serveSite bool // serve the site
|
|
}
|
|
|
|
func init() {
|
|
flag.StringVar(&cfg.title, "title", "", "site title")
|
|
flag.StringVar(&cfg.url, "url", "", "site URL")
|
|
flag.BoolVar(&cfg.toAtom, "atom", false, "output Atom feed")
|
|
flag.BoolVar(&cfg.toHTML, "html", false, "output HTML")
|
|
flag.BoolVar(&cfg.serveSite, "serve", false, "serve the site")
|
|
}
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
if err := run(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
if cfg.serveSite {
|
|
serve()
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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 {
|
|
return err
|
|
}
|
|
// Sort content
|
|
dir.sort()
|
|
// Manipulate content
|
|
if err := manipulate(dir); err != nil {
|
|
return err
|
|
}
|
|
if cfg.toAtom {
|
|
if err := createFeeds(dir); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Write content
|
|
if err := write(dir, dstDir, outputGemini); err != nil {
|
|
return err
|
|
}
|
|
if cfg.toHTML {
|
|
if err := write(dir, htmlDst, outputHTML); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// serve the site
|
|
func serve() error {
|
|
var server gemini.Server
|
|
server.Certificates.Load("/var/lib/gemini/certs")
|
|
server.Register("localhost", gemini.FileServer(gemini.Dir("dst")))
|
|
return server.ListenAndServe()
|
|
}
|
|
|
|
// 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
|
|
tmpl := findTemplate(dir.Permalink, indexTmpl)
|
|
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
|
|
tmpl := findTemplate(dir.Pages[i].Permalink, pageTmpl)
|
|
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?")
|
|
|
|
// NewPage returns a new Page with the given path and content.
|
|
func NewPage(path string, content []byte) *Page {
|
|
// Try to parse the date from the page filename
|
|
var date time.Time
|
|
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 {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to parse the title from the contents
|
|
var title string
|
|
if submatches := titleRE.FindSubmatch(content); submatches != nil {
|
|
title = string(submatches[1])
|
|
// Remove the title from the contents
|
|
content = content[len(submatches[0]):]
|
|
}
|
|
|
|
permalink := strings.TrimSuffix(path, ".gmi")
|
|
return &Page{
|
|
Permalink: "/" + permalink + "/",
|
|
Title: title,
|
|
Date: date,
|
|
content: content,
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
text := gemini.Parse(r)
|
|
content = []byte(textToHTML(text))
|
|
|
|
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
|
|
}
|