Add support for Gemini feeds

This commit is contained in:
adnano 2020-11-20 12:07:38 -05:00
parent 3c4d934f55
commit f14999d88a
9 changed files with 555 additions and 529 deletions

View file

@ -5,6 +5,7 @@
VERSION=0.0.0 VERSION=0.0.0
PREFIX?=/usr/local PREFIX?=/usr/local
BINDIR?=$(PREFIX)/bin
MANDIR?=$(PREFIX)/share/man MANDIR?=$(PREFIX)/share/man
VPATH=doc VPATH=doc
@ -35,12 +36,12 @@ clean:
$(RM) $(DOCS) $(RM) $(DOCS)
install: all install: all
mkdir -p $(DESTDIR)$(PREFIX)/bin $(DESTDIR)$(MANDIR)/man1 mkdir -p $(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man1
install -m755 kiln $(DESTDIR)$(PREFIX)/bin install -m755 kiln $(DESTDIR)$(BINDIR)/kiln
install -m755 kiln.1 $(MANDIR)/man1/kiln.1 install -m755 kiln.1 $(MANDIR)/man1/kiln.1
uninstall: uninstall:
$(RM) $(DESTDIR)$(PREFIX)/bin/kiln $(RM) $(DESTDIR)$(BINDIR)/kiln
$(RM) $(DESTDIR)$(MANDIR)/man1/kiln.1 $(RM) $(DESTDIR)$(MANDIR)/man1/kiln.1
.PHONY: all doc clean install uninstall .PHONY: all doc clean install uninstall

View file

@ -1,14 +1,14 @@
# kiln # kiln
A simple static site generator for Gemini. A simple static site generator for Gemini sites.
## Features ## Features
- Simple and fast - Simple and fast
- Gemini support - Gemini support
- Generate Gemini feeds
- Go templates - Go templates
- Export to HTML - Output HTML
- Generate Atom feeds
## Installation ## Installation

62
config.go Normal file
View file

@ -0,0 +1,62 @@
package main
import (
"os"
"text/template"
"git.sr.ht/~adnano/go-ini"
)
// Config contains site configuration.
type Config struct {
Title string
Feeds map[string]string
Templates *Templates
}
// NewConfig returns a new configuration.
func NewConfig() *Config {
return &Config{
Feeds: map[string]string{},
}
}
// Load loads the configuration from the provided path.
func (c *Config) Load(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
// Load config
return ini.Parse(f, func(section, key, value string) {
switch section {
case "":
switch key {
case "title":
c.Title = value
}
case "feeds":
c.Feeds[key] = value
}
})
}
// LoadTemplates loads templates from the provided path.
func (c *Config) LoadTemplates(path string) error {
// Site contains site metadata passed to templates
type Site struct {
Title string
}
// Load templates
c.Templates = NewTemplates()
c.Templates.Funcs(template.FuncMap{
"site": func() Site {
return Site{
Title: c.Title,
}
},
})
return c.Templates.Load(path)
}

201
dir.go Normal file
View file

@ -0,0 +1,201 @@
package main
import (
"io/ioutil"
"os"
pathpkg "path"
"sort"
"strings"
"time"
)
// Dir represents a directory.
type Dir struct {
Path string // Directory path.
Pages []*Page // Pages in this directory.
Dirs []*Dir // Subdirectories.
files map[string][]byte // Static files.
index *Page // The index page.
}
// NewDir returns a new Dir with the given path.
func NewDir(path string) *Dir {
if path == "" {
path = "/"
} else {
path = "/" + path + "/"
}
return &Dir{
Path: path,
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(pathpkg.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 := pathpkg.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 := pathpkg.Join(srcDir, path)
content, err := ioutil.ReadFile(srcPath)
if err != nil {
return err
}
if pathpkg.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
}
// manipulate processes and manipulates the directory's contents.
func (d *Dir) manipulate(cfg *Config) error {
// Create the directory index file, if it doesn't exist
if d.index == nil {
var b strings.Builder
tmpl := cfg.Templates.FindTemplate(d.Path, "index.gmi")
if err := tmpl.Execute(&b, d); err != nil {
return err
}
d.index = &Page{
Path: d.Path,
Content: b.String(),
}
}
// Manipulate pages
for i := range d.Pages {
var b strings.Builder
tmpl := cfg.Templates.FindTemplate(d.Pages[i].Path, "page.gmi")
if err := tmpl.Execute(&b, d.Pages[i]); err != nil {
return err
}
d.Pages[i].Content = b.String()
}
// Feed represents a feed.
type Feed struct {
Title string // Feed title.
Updated time.Time // Last updated time.
Entries []*Page // Feed entries.
}
// Create feeds
if title, ok := cfg.Feeds[d.Path]; ok {
var b strings.Builder
feed := &Feed{
Title: title,
Updated: time.Now(),
Entries: d.Pages,
}
tmpl := cfg.Templates.FindTemplate(d.Path, "feed.gmi")
if err := tmpl.Execute(&b, feed); err != nil {
return err
}
d.Pages = append(d.Pages, &Page{
Path: pathpkg.Join(d.Path, "feed"),
Content: b.String(),
})
}
// Manipulate subdirectories
for _, d := range d.Dirs {
if err := d.manipulate(cfg); err != nil {
return err
}
}
return nil
}
// write writes the Dir to the provided destination path.
func (d *Dir) write(dstDir string, format outputFormat, cfg *Config) error {
// Create the directory
dirPath := pathpkg.Join(dstDir, d.Path)
if err := os.MkdirAll(dirPath, 0755); err != nil {
return err
}
// Write static files
for path := range d.files {
dstPath := pathpkg.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, cfg)
dstPath := pathpkg.Join(dstDir, path)
dir := pathpkg.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, cfg)
dstPath := pathpkg.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, cfg)
}
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()
}
}

View file

@ -6,89 +6,125 @@ kiln - a simple static site generator for Gemini sites
# SYNOPSIS # SYNOPSIS
_kiln_ _kiln_ [--html]
# OPTIONS
\--html
If this flag is present, kiln will output HTML as well as Gemini text.
# SITE STRUCTURE # SITE STRUCTURE
A kiln site is organized in the following way: A kiln site is structured in the following way:
|[ *Directory* [[ *Directory*
:[ *Description* :[ *Description*
| src/ | src/
: Site source : Site source
| templates/ | templates/
: Templates : Site templates
| dst/ | dst/
: Site destination : Site destination
| dst.html/ | html/
: Site HTML destination : Site HTML destination
# TEMPLATES # TEMPLATES
kiln looks for templates in the "templates" directory. kiln looks for templates in the *templates* directory.
The following templates are accepted: The following templates are supported:
|[ *Template* [[ *Template*
:[ *Description* :[ *Description*
| page.gmi | page.gmi
: Page template : Page template
| index.gmi | index.gmi
: Directory index template : Directory index template
| feed.gmi
: Gemini feed template
| output.html | output.html
: text/gemini to HTML template : HTML output template
The scope of templates can be limited by placing them in subdirectories of the templates directory.
For example, the template templates/blog/page.gmi will apply to all pages in src/blog.
## FUNCTIONS
All templates have the following functions available to them:
[[ *Function*
:[ *Description*
| site
: Returns site metadata
## SITE METADATA
Site metadata contains the following information:
[[ *Variable*
:[ *Description*
| Title
: The title of the site, which can be specified in the site configuration.
## PAGE TEMPLATES ## PAGE TEMPLATES
Page templates are provided with the following information: Page templates are provided with the following information:
|[ *Variable* [[ *Variable*
:[ *Description* :[ *Description*
| Title | Title
: The title of the page : The title of the page
| Date | Date
: The date of the page : The date of the page
| Permalink | Path
: Permalink to the page : Path to the page
| Content | Content
: The contents of the page : The contents of the page
Pages can specify dates in their filenames. Pages can specify dates in their filenames.
kiln will recognize the date and remove it from the permalink. For example, src/2020-11-20-Hello-world.gmi will have a path of /Hello-world and a date of November 20, 2020.
Pages can also specify titles in their content. Pages can specify a title in a top-level heading line.
kiln will parse and remove the title from the content. The heading must be the first line in the page, and can optionally be followed by a blank line.
Both lines will be removed from the page content.
## INDEX TEMPLATES ## INDEX TEMPLATES
Index templates are provided with the following information: Index templates are provided with the following information:
|[ *Variable* [[ *Variable*
:[ *Description* :[ *Description*
| Permalink | Path
: Permalink to the directory : Path to the directory
| Pages | Pages
: List of pages in this directory : List of pages in this directory
| Directories | Dirs
: List of subdirectories : List of subdirectories
# PERMALINKS ## FEED TEMPLATES
Every page and directory in the site is assigned a permalink. Feed templates are provided with the following information:
Permalinks are absolute and point to the destination file.
All files are written to their permalink plus "index.gmi".
# FRONTMATTER [[ *Variable*
Frontmatter uses the _ini_ format.
Keys and values are separated by '='.
The following keys are supported:
|[ *Key*
:[ *Description* :[ *Description*
| title | Title
: The title of the page : Title of the feed
| date | Path
: The date of the page, specified in the format "2006-01-02". : Path to the feed directory
| Entries
: List of feed entries
Feeds are written to the directory path plus "feed".
## HTML TEMPLATES
HTML output templates are provided with the following information:
[[ *Variable*
:[ *Description*
| Title
: Title of the page
| Content
: HTML contents of the page
# CONFIGURATION # CONFIGURATION
@ -96,19 +132,16 @@ kiln looks for a configuration file named "config.ini".
The configuration file uses the _ini_ format. The configuration file uses the _ini_ format.
The following keys are supported: The following keys are supported:
|[ *Key* [[ *Key*
:[ *Description* :[ *Description*
:[ *Default value*
| title | title
: Site title : Site title
: ""
| url The following sections are supported:
: Site URL
: "" [[ *Section*
| atom :[ *Description*
: Output an atom feed | feeds
: false : A list of feeds. Each key denotes a path to a directory, and each value
| html denotes the title of the feed.
: Output HTML
: false

22
html.go
View file

@ -1,27 +1,27 @@
package main package main
import ( import (
"bytes"
"fmt" "fmt"
"html" "html"
"strings"
"git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini"
) )
// textToHTML returns the Gemini text response as HTML. // textToHTML returns the Gemini text response as HTML.
func textToHTML(text gemini.Text) string { func textToHTML(text gemini.Text) []byte {
var b strings.Builder var b bytes.Buffer
var pre bool var pre bool
var list bool var list bool
for _, l := range text { for _, l := range text {
if _, ok := l.(gemini.LineListItem); ok { if _, ok := l.(gemini.LineListItem); ok {
if !list { if !list {
list = true list = true
fmt.Fprint(&b, "<ul>\n") b.WriteString("<ul>\n")
} }
} else if list { } else if list {
list = false list = false
fmt.Fprint(&b, "</ul>\n") b.WriteString("</ul>\n")
} }
switch l.(type) { switch l.(type) {
case gemini.LineLink: case gemini.LineLink:
@ -35,9 +35,9 @@ func textToHTML(text gemini.Text) string {
case gemini.LinePreformattingToggle: case gemini.LinePreformattingToggle:
pre = !pre pre = !pre
if pre { if pre {
fmt.Fprint(&b, "<pre>\n") b.WriteString("<pre>\n")
} else { } else {
fmt.Fprint(&b, "</pre>\n") b.WriteString("</pre>\n")
} }
case gemini.LinePreformattedText: case gemini.LinePreformattedText:
text := string(l.(gemini.LinePreformattedText)) text := string(l.(gemini.LinePreformattedText))
@ -60,17 +60,17 @@ func textToHTML(text gemini.Text) string {
case gemini.LineText: case gemini.LineText:
text := string(l.(gemini.LineText)) text := string(l.(gemini.LineText))
if text == "" { if text == "" {
fmt.Fprint(&b, "<br>\n") b.WriteString("<br>\n")
} else { } else {
fmt.Fprintf(&b, "<p>%s</p>\n", html.EscapeString(text)) fmt.Fprintf(&b, "<p>%s</p>\n", html.EscapeString(text))
} }
} }
} }
if pre { if pre {
fmt.Fprint(&b, "</pre>\n") b.WriteString("</pre>\n")
} }
if list { if list {
fmt.Fprint(&b, "</ul>\n") b.WriteString("</ul>\n")
} }
return b.String() return b.Bytes()
} }

486
main.go
View file

@ -3,487 +3,93 @@ package main
import ( import (
"bytes" "bytes"
"flag" "flag"
"fmt"
"io/ioutil"
"log" "log"
"os" pathpkg "path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings" "strings"
"text/template"
"time"
"git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini"
"git.sr.ht/~adnano/go-ini"
) )
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
}
func main() { 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
}
}
})
}
flag.Parse()
if err := run(); err != nil { if err := run(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
// 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 { func run() error {
// Load default templates // whether or not to output HTML
loadTemplate(atomTmpl, atom_xml) var toHTML bool
loadTemplate(indexTmpl, index_gmi) flag.BoolVar(&toHTML, "html", false, "output HTML")
loadTemplate(pageTmpl, page_gmi) flag.Parse()
loadTemplate(htmlTmpl, output_html)
// Load templates // Load config
filepath.Walk(tmplDir, func(path string, info os.FileInfo, err error) error { cfg := NewConfig()
if err != nil { if err := cfg.Load("config.ini"); err != nil {
return err return err
} }
if info.Mode().IsRegular() { if err := cfg.LoadTemplates("templates"); err != nil {
b, err := ioutil.ReadFile(path) return err
if err != nil { }
return err
}
// Remove "templates/" from beginning of path
path = strings.TrimPrefix(path, tmplDir)
loadTemplate(path, string(b))
}
return nil
})
// Load content // Load content
dir := NewDir("") dir := NewDir("")
if err := dir.read(srcDir, ""); err != nil { if err := dir.read("src", ""); err != nil {
return err return err
} }
// Sort content
dir.sort() dir.sort()
// Manipulate content // Manipulate content
if err := manipulate(dir); err != nil { if err := dir.manipulate(cfg); err != nil {
return err return err
} }
if cfg.toAtom {
if err := createFeeds(dir); err != nil {
return err
}
}
// Write content // Write content
if err := write(dir, dstDir, outputGemini); err != nil { if err := dir.write("dst", outputGemini, cfg); err != nil {
return err return err
} }
if cfg.toHTML { if toHTML {
if err := write(dir, htmlDst, outputHTML); err != nil { if err := dir.write("html", outputHTML, cfg); err != nil {
return err return err
} }
} }
return nil return nil
} }
// 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?")
// Regexp to parse frontmatter from Gemini files
var frontmatterRE = regexp.MustCompile("---\r?\n(?s)(.*)(?-s)---\r?\n?")
// 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
}
// 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
if submatches := titleRE.FindSubmatch(content); submatches != nil {
page.Title = string(submatches[1])
// Remove the title from the contents
content = content[len(submatches[0]):]
}
}
page.Permalink = "/" + strings.TrimSuffix(path, ".gmi") + "/"
page.content = content
return &page
}
// 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. // outputFormat represents an output format.
type outputFormat func(*Page) (path string, content []byte) type outputFormat func(*Page, *Config) (path string, content []byte)
func outputGemini(p *Page) (path string, content []byte) { // outputGemini outputs the page as Gemini text.
path = filepath.Join(p.Permalink, indexPath) func outputGemini(p *Page, cfg *Config) (path string, content []byte) {
content = p.content path = p.Path
if strings.HasSuffix(path, "/") {
path += "index.gmi"
} else {
path += "/index.gmi"
}
content = []byte(p.Content)
return return
} }
func outputHTML(p *Page) (path string, content []byte) { // outputHTML outputs the page as HTML.
const indexPath = "index.html" func outputHTML(p *Page, cfg *Config) (path string, content []byte) {
path = filepath.Join(p.Permalink, indexPath) path = p.Path
if strings.HasSuffix(path, "/") {
path += "index.html"
} else {
path += "/index.html"
}
r := bytes.NewReader(p.content) r := strings.NewReader(p.Content)
text := gemini.ParseText(r) text := gemini.ParseText(r)
content = []byte(textToHTML(text)) content = textToHTML(text)
type tmplCtx struct { // html template context
Title string type htmlCtx struct {
Content string Title string // page title
Content string // page HTML contents
} }
var b bytes.Buffer var b bytes.Buffer
tmpl := templates[htmlTmpl] tmpl := cfg.Templates.FindTemplate(pathpkg.Dir(path), "output.html")
tmpl.Execute(&b, &tmplCtx{ tmpl.Execute(&b, &htmlCtx{
Title: p.Title, Title: p.Title,
Content: string(content), Content: string(content),
}) })

62
page.go Normal file
View file

@ -0,0 +1,62 @@
package main
import (
pathpkg "path"
"regexp"
"strings"
"time"
)
// Page represents a page.
type Page struct {
Title string // The title of this page.
Path string // The path to this page.
Date time.Time // The date of the page.
Content string // The content of this page.
}
// titleRe is a regexp to parse titles from Gemini files.
// It also matches the next line if it is empty.
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 {
var page Page
// 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)
}
}
}
// 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]):]
}
// Remove extension from path
page.Path = "/" + strings.TrimSuffix(path, ".gmi")
page.Content = string(content)
return &page
}

View file

@ -1,33 +1,86 @@
package main package main
// TODO: Use go:embed import (
"io/ioutil"
"os"
"path/filepath"
"strings"
"text/template"
)
// Default atom feed template // Templates contains site templates.
const atom_xml = `<?xml version="1.0" encoding="utf-8"?> type Templates struct {
<feed xmlns="http://www.w3.org/2005/Atom"> tmpls map[string]*template.Template
<title>{{ .Title }}</title> funcs template.FuncMap
<link href="{{ .URL }}"/> }
<link rel="self" href="{{ .URL }}{{ .Path }}"/>
<updated>{{ .Updated.Format "2006-01-02T15:04:05Z07:00" }}</updated> // NewTemplates returns a new Templates with the default templates.
<id>{{ .URL }}{{ .Path }}</id> func NewTemplates() *Templates {
{{- $url := .URL -}} t := &Templates{
{{- range .Entries }} tmpls: map[string]*template.Template{},
<entry> }
<title>{{ .Title }}</title> // Load default templates
<link href="{{ $url }}{{ .Permalink }}"/> t.LoadTemplate("/index.gmi", index_gmi)
<id>{{ $url }}{{ .Permalink }}</id> t.LoadTemplate("/page.gmi", page_gmi)
<updated>{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}</updated> t.LoadTemplate("/feed.gmi", feed_gmi)
<content src="{{ $url }}{{ .Permalink }}" type="text/gemini"></content> t.LoadTemplate("/output.html", output_html)
</entry> return t
}
// Funcs sets the functions available to newly created templates.
func (t *Templates) Funcs(funcs template.FuncMap) {
t.funcs = funcs
}
// LoadTemplate loads a template from the provided path and content.
func (t *Templates) LoadTemplate(path string, content string) {
tmpl := template.New(path)
tmpl.Funcs(t.funcs)
template.Must(tmpl.Parse(content))
t.tmpls[path] = tmpl
}
// Load loads templates from the provided directory
func (t *Templates) Load(dir string) error {
return filepath.Walk(dir, 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 directory from beginning of path
path = strings.TrimPrefix(path, dir)
t.LoadTemplate(path, string(b))
}
return nil
})
}
// FindTemplate searches recursively for a template for the given path.
func (t *Templates) FindTemplate(path string, tmpl string) *template.Template {
for {
tmplPath := filepath.Join(path, tmpl)
if t, ok := t.tmpls[tmplPath]; ok {
return t
}
slash := path == "/"
path = filepath.Dir(path)
if slash && path == "/" {
break
}
}
return nil
}
// Default index template
const index_gmi = `# Index of {{ .Path }}
{{ range .Dirs }}=> {{ .Path }}
{{ end -}} {{ end -}}
</feed>` {{ range .Pages }}=> {{ .Path }}
// Default directory index template
const index_gmi = `# Index of {{ .Permalink }}
{{ range .Dirs }}=> {{ .Permalink }}
{{ end -}}
{{ range .Pages }}=> {{ .Permalink }}
{{ end -}}` {{ end -}}`
// Default page template // Default page template
@ -35,6 +88,14 @@ const page_gmi = `# {{ .Title }}
{{ .Content }}` {{ .Content }}`
// Default feed template
const feed_gmi = `# {{ .Title }}
Last updated at {{ .Updated.Format "2006-01-02" }}
{{ range .Entries }}=> {{ .Path }} {{ .Date.Format "2006-01-02" }} {{ .Title }}
{{ end -}}`
// Default template for html output // Default template for html output
const output_html = `<!DOCTYPE html> const output_html = `<!DOCTYPE html>
<meta charset="utf-8"> <meta charset="utf-8">