mirror of
https://git.sr.ht/~adnano/kiln
synced 2024-09-08 03:10:36 +02:00
416 lines
9.5 KiB
Go
416 lines
9.5 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
pathpkg "path"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Page represents a page.
|
|
type Page struct {
|
|
Title string `yaml:"title"`
|
|
Date time.Time `yaml:"date"`
|
|
Weight int `yaml:"weight"`
|
|
Outputs []string `yaml:"outputs"`
|
|
Template string `yaml:"template"`
|
|
Params map[string]interface{} `yaml:"params"`
|
|
Path string `yaml:"-"`
|
|
FilePath string `yaml:"-"`
|
|
URL string `yaml:"-"`
|
|
Content string `yaml:"-"`
|
|
Prev *Page `yaml:"-"`
|
|
Next *Page `yaml:"-"`
|
|
Pages []*Page `yaml:"-"`
|
|
Dirs []*Page `yaml:"-"`
|
|
pages map[string]*Page
|
|
feeds map[string][]byte
|
|
index bool
|
|
}
|
|
|
|
// read reads from a directory and indexes the files and directories within it.
|
|
func (p *Page) read(srcDir string, task *Task, cfg *Site) error {
|
|
return p._read(os.DirFS(srcDir), ".", task, cfg)
|
|
}
|
|
|
|
func (p *Page) _read(fsys fs.FS, path string, task *Task, cfg *Site) error {
|
|
entries, err := fs.ReadDir(fsys, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
path := pathpkg.Join(path, name)
|
|
if entry.IsDir() {
|
|
// Ignore directories beginning with "_"
|
|
if strings.HasPrefix(name, "_") {
|
|
continue
|
|
}
|
|
// Gather directory data
|
|
dirPath := "/" + path + "/"
|
|
dir := &Page{
|
|
Path: dirPath,
|
|
FilePath: path,
|
|
URL: task.URL + dirPath,
|
|
}
|
|
if err := dir._read(fsys, path, task, cfg); err != nil {
|
|
return err
|
|
}
|
|
p.Dirs = append(p.Dirs, dir)
|
|
p.addPage(name, dir)
|
|
} else if ext := pathpkg.Ext(name); task.Match(ext) {
|
|
// Ignore pages beginning with "_" with the exception of _index pages
|
|
namePrefix := strings.TrimSuffix(name, ext)
|
|
if strings.HasPrefix(name, "_") && namePrefix != "_index" {
|
|
continue
|
|
}
|
|
|
|
content, err := fs.ReadFile(fsys, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
page := &Page{
|
|
FilePath: path,
|
|
}
|
|
if namePrefix == "_index" {
|
|
p.index = true
|
|
page = p
|
|
}
|
|
|
|
// Try to parse the date from the page filename
|
|
const layout = "2006-01-02"
|
|
base := pathpkg.Base(path)
|
|
if len(base) >= len(layout) {
|
|
dateStr := base[:len(layout)]
|
|
if time, err := time.Parse(layout, dateStr); err == nil {
|
|
page.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 := pathpkg.Dir(path)
|
|
if dir == "." {
|
|
dir = ""
|
|
}
|
|
path = pathpkg.Join(dir, base)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract frontmatter from content
|
|
frontmatter, content := extractFrontmatter(content)
|
|
if len(frontmatter) != 0 {
|
|
if err := yaml.Unmarshal(frontmatter, page); err != nil {
|
|
log.Printf("failed to parse frontmatter for %q: %v", path, err)
|
|
} else if page.Outputs != nil {
|
|
// Enforce page outputs
|
|
if len(page.Outputs) == 0 || task.Name == "" {
|
|
continue
|
|
}
|
|
found := false
|
|
for _, output := range page.Outputs {
|
|
if output == task.Name {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Trim leading newlines from content
|
|
content = bytes.TrimLeft(content, "\r\n")
|
|
}
|
|
|
|
if cmd, ok := task.preprocess[strings.TrimPrefix(ext, ".")]; ok {
|
|
var buf bytes.Buffer
|
|
if err := execute(cmd, bytes.NewReader(content), &buf); err != nil {
|
|
return err
|
|
}
|
|
content = buf.Bytes()
|
|
}
|
|
page.Content = string(content)
|
|
|
|
if !page.index {
|
|
if namePrefix == "index" {
|
|
path = "/" + strings.TrimSuffix(path, name)
|
|
} else {
|
|
path = "/" + strings.TrimSuffix(path, ext)
|
|
if task.UglyURLs {
|
|
path += task.OutputExt
|
|
} else {
|
|
path += "/"
|
|
}
|
|
}
|
|
page.Path = path
|
|
if permalink, ok := task.permalinks[p.Path]; ok {
|
|
var b strings.Builder
|
|
permalink.Execute(&b, page)
|
|
page.Path = b.String()
|
|
}
|
|
page.URL = task.URL + page.Path
|
|
p.Pages = append(p.Pages, page)
|
|
p.addPage(name, page)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Page) addPage(name string, page *Page) {
|
|
if p.pages == nil {
|
|
p.pages = map[string]*Page{}
|
|
}
|
|
p.pages[name] = page
|
|
}
|
|
|
|
// process processes the directory's contents.
|
|
func (p *Page) process(cfg *Site, task *Task) error {
|
|
// Build feeds before templates are applied to the page contents
|
|
for _, feed := range task.feeds[p.FilePath] {
|
|
b, err := p.buildFeed(cfg, feed)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.addFeed(feed.Output, b)
|
|
}
|
|
|
|
if task.TemplateExt != "" {
|
|
// Create index
|
|
if p.index {
|
|
tmplName := p.Template
|
|
if tmplName == "" {
|
|
tmplName = "index"
|
|
}
|
|
tmpl, ok := cfg.templates.FindTemplate(p.FilePath, tmplName+task.TemplateExt)
|
|
if !ok && p.Template != "" {
|
|
return fmt.Errorf("failed to generate index page %q: template %q specified in frontmatter but not found", p.Path, tmplName)
|
|
}
|
|
if ok {
|
|
var b strings.Builder
|
|
if err := tmpl.Execute(&b, p); err != nil {
|
|
return err
|
|
}
|
|
p.Content = b.String()
|
|
}
|
|
}
|
|
|
|
// Process pages
|
|
for i := range p.Pages {
|
|
tmplName := p.Pages[i].Template
|
|
if tmplName == "" {
|
|
tmplName = "page"
|
|
}
|
|
var b strings.Builder
|
|
tmpl, ok := cfg.templates.FindTemplate(p.FilePath, tmplName+task.TemplateExt)
|
|
if !ok && p.Pages[i].Template != "" {
|
|
return fmt.Errorf("failed to generate page %q: template %q specified in frontmatter but not found", p.Pages[i].Path, tmplName)
|
|
}
|
|
if ok {
|
|
if err := tmpl.Execute(&b, p.Pages[i]); err != nil {
|
|
return err
|
|
}
|
|
p.Pages[i].Content = b.String()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process subdirectories
|
|
for _, d := range p.Dirs {
|
|
if err := d.process(cfg, task); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildFeed build the feed of the directory
|
|
func (p *Page) buildFeed(cfg *Site, feed Feed) ([]byte, error) {
|
|
// Feed represents a feed.
|
|
type Feed struct {
|
|
Title string
|
|
Path string
|
|
URL string
|
|
Pages []*Page
|
|
}
|
|
|
|
tmpl, ok := cfg.templates.FindTemplate(p.FilePath, feed.Template)
|
|
if !ok {
|
|
return nil, fmt.Errorf("failed to generate feed %q: missing feed template %q", feed.Title, feed.Template)
|
|
}
|
|
|
|
var b bytes.Buffer
|
|
data := Feed{
|
|
Title: feed.Title,
|
|
Path: p.Path,
|
|
URL: p.URL,
|
|
Pages: p.Pages,
|
|
}
|
|
if err := tmpl.Execute(&b, data); err != nil {
|
|
return nil, err
|
|
}
|
|
return b.Bytes(), nil
|
|
}
|
|
|
|
func (p *Page) addFeed(name string, content []byte) {
|
|
if p.feeds == nil {
|
|
p.feeds = map[string][]byte{}
|
|
}
|
|
p.feeds[name] = content
|
|
}
|
|
|
|
// write writes the directory's contents to the provided destination path.
|
|
func (p *Page) write(dstDir string, task *Task) error {
|
|
dirPath := pathpkg.Join(dstDir, p.Path)
|
|
|
|
// Write pages
|
|
for _, page := range p.Pages {
|
|
dstPath := pathpkg.Join(dstDir, page.Path)
|
|
if !task.UglyURLs {
|
|
dstPath = pathpkg.Join(dstPath, "index"+task.OutputExt)
|
|
}
|
|
if err := page.writeTo(dstPath, task); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Write index page
|
|
if p.index {
|
|
dstPath := pathpkg.Join(dstDir, p.Path, "index"+task.OutputExt)
|
|
if err := p.writeTo(dstPath, task); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Write feeds
|
|
for name, content := range p.feeds {
|
|
dstPath := pathpkg.Join(dstDir, name)
|
|
os.MkdirAll(dirPath, 0755)
|
|
if err := os.WriteFile(dstPath, content, 0644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Write subdirectories
|
|
for _, dir := range p.Dirs {
|
|
dir.write(dstDir, task)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Page) writeTo(dstPath string, task *Task) error {
|
|
var content []byte
|
|
if cmd := task.postprocess; cmd != nil {
|
|
var buf bytes.Buffer
|
|
if err := execute(cmd, strings.NewReader(p.Content), &buf); err != nil {
|
|
return err
|
|
}
|
|
content = buf.Bytes()
|
|
} else {
|
|
content = []byte(p.Content)
|
|
}
|
|
|
|
dir := pathpkg.Dir(dstPath)
|
|
os.MkdirAll(dir, 0755)
|
|
if err := os.WriteFile(dstPath, content, 0644); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sort sorts the directory's pages by weight, then date, then filepath.
|
|
func (p *Page) sort() {
|
|
sortPages(p.Pages)
|
|
sortPages(p.Dirs)
|
|
|
|
// Sort subdirectories
|
|
for _, d := range p.Dirs {
|
|
d.sort()
|
|
}
|
|
}
|
|
|
|
func sortPages(pages []*Page) {
|
|
sort.Slice(pages, func(i, j int) bool {
|
|
pi, pj := pages[i], pages[j]
|
|
return pi.FilePath < pj.FilePath
|
|
})
|
|
|
|
sort.SliceStable(pages, func(i, j int) bool {
|
|
pi, pj := pages[i], pages[j]
|
|
return pi.Date.After(pj.Date)
|
|
})
|
|
|
|
sort.SliceStable(pages, func(i, j int) bool {
|
|
pi, pj := pages[i], pages[j]
|
|
return pi.Weight < pj.Weight
|
|
})
|
|
|
|
for i := range pages {
|
|
if i-1 >= 0 {
|
|
pages[i].Prev = pages[i-1]
|
|
}
|
|
if i+1 < len(pages) {
|
|
pages[i].Next = pages[i+1]
|
|
}
|
|
}
|
|
}
|
|
|
|
// execute runs a command.
|
|
func execute(command []string, input io.Reader, output io.Writer) error {
|
|
if len(command) == 0 {
|
|
return nil
|
|
}
|
|
cmd := exec.Command(command[0], command[1:]...)
|
|
cmd.Stdin = input
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Stdout = output
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetPage returns the page with the given path.
|
|
func (p *Page) GetPage(path string) *Page {
|
|
path = pathpkg.Clean(path)
|
|
if path == "." {
|
|
return p
|
|
}
|
|
if strings.HasPrefix(path, "/") {
|
|
path = strings.TrimPrefix(path, "/")
|
|
if p.FilePath != "." && !strings.HasPrefix(path, p.FilePath) {
|
|
return nil
|
|
}
|
|
path = strings.TrimPrefix(path, p.FilePath)
|
|
if path == "" {
|
|
return p
|
|
}
|
|
path = strings.TrimPrefix(path, "/")
|
|
}
|
|
|
|
page := p
|
|
for _, part := range strings.Split(path, "/") {
|
|
page = page.pages[part]
|
|
if page == nil {
|
|
break
|
|
}
|
|
}
|
|
return page
|
|
}
|