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

View file

@ -1,14 +1,14 @@
# kiln
A simple static site generator for Gemini.
A simple static site generator for Gemini sites.
## Features
- Simple and fast
- Gemini support
- Generate Gemini feeds
- Go templates
- Export to HTML
- Generate Atom feeds
- Output HTML
## 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
_kiln_
_kiln_ [--html]
# OPTIONS
\--html
If this flag is present, kiln will output HTML as well as Gemini text.
# SITE STRUCTURE
A kiln site is organized in the following way:
A kiln site is structured in the following way:
|[ *Directory*
[[ *Directory*
:[ *Description*
| src/
: Site source
| templates/
: Templates
: Site templates
| dst/
: Site destination
| dst.html/
| html/
: Site HTML destination
# TEMPLATES
kiln looks for templates in the "templates" directory.
The following templates are accepted:
kiln looks for templates in the *templates* directory.
The following templates are supported:
|[ *Template*
[[ *Template*
:[ *Description*
| page.gmi
: Page template
| index.gmi
: Directory index template
| feed.gmi
: Gemini feed template
| 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 are provided with the following information:
|[ *Variable*
[[ *Variable*
:[ *Description*
| Title
: The title of the page
| Date
: The date of the page
| Permalink
: Permalink to the page
| Path
: Path to the page
| Content
: The contents of the page
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.
kiln will parse and remove the title from the content.
Pages can specify a title in a top-level heading line.
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 are provided with the following information:
|[ *Variable*
[[ *Variable*
:[ *Description*
| Permalink
: Permalink to the directory
| Path
: Path to the directory
| Pages
: List of pages in this directory
| Directories
| Dirs
: List of subdirectories
# PERMALINKS
## FEED TEMPLATES
Every page and directory in the site is assigned a permalink.
Permalinks are absolute and point to the destination file.
All files are written to their permalink plus "index.gmi".
Feed templates are provided with the following information:
# FRONTMATTER
Frontmatter uses the _ini_ format.
Keys and values are separated by '='.
The following keys are supported:
|[ *Key*
[[ *Variable*
:[ *Description*
| title
: The title of the page
| date
: The date of the page, specified in the format "2006-01-02".
| Title
: Title of the feed
| Path
: 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
@ -96,19 +132,16 @@ kiln looks for a configuration file named "config.ini".
The configuration file uses the _ini_ format.
The following keys are supported:
|[ *Key*
[[ *Key*
:[ *Description*
:[ *Default value*
| title
: Site title
: ""
| url
: Site URL
: ""
| atom
: Output an atom feed
: false
| html
: Output HTML
: false
The following sections are supported:
[[ *Section*
:[ *Description*
| feeds
: A list of feeds. Each key denotes a path to a directory, and each value
denotes the title of the feed.

22
html.go
View file

@ -1,27 +1,27 @@
package main
import (
"bytes"
"fmt"
"html"
"strings"
"git.sr.ht/~adnano/go-gemini"
)
// textToHTML returns the Gemini text response as HTML.
func textToHTML(text gemini.Text) string {
var b strings.Builder
func textToHTML(text gemini.Text) []byte {
var b bytes.Buffer
var pre bool
var list bool
for _, l := range text {
if _, ok := l.(gemini.LineListItem); ok {
if !list {
list = true
fmt.Fprint(&b, "<ul>\n")
b.WriteString("<ul>\n")
}
} else if list {
list = false
fmt.Fprint(&b, "</ul>\n")
b.WriteString("</ul>\n")
}
switch l.(type) {
case gemini.LineLink:
@ -35,9 +35,9 @@ func textToHTML(text gemini.Text) string {
case gemini.LinePreformattingToggle:
pre = !pre
if pre {
fmt.Fprint(&b, "<pre>\n")
b.WriteString("<pre>\n")
} else {
fmt.Fprint(&b, "</pre>\n")
b.WriteString("</pre>\n")
}
case gemini.LinePreformattedText:
text := string(l.(gemini.LinePreformattedText))
@ -60,17 +60,17 @@ func textToHTML(text gemini.Text) string {
case gemini.LineText:
text := string(l.(gemini.LineText))
if text == "" {
fmt.Fprint(&b, "<br>\n")
b.WriteString("<br>\n")
} else {
fmt.Fprintf(&b, "<p>%s</p>\n", html.EscapeString(text))
}
}
}
if pre {
fmt.Fprint(&b, "</pre>\n")
b.WriteString("</pre>\n")
}
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 (
"bytes"
"flag"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
pathpkg "path"
"strings"
"text/template"
"time"
"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() {
// 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 {
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 {
// 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
})
// whether or not to output HTML
var toHTML bool
flag.BoolVar(&toHTML, "html", false, "output HTML")
flag.Parse()
// Load config
cfg := NewConfig()
if err := cfg.Load("config.ini"); err != nil {
return err
}
if err := cfg.LoadTemplates("templates"); err != nil {
return err
}
// Load content
dir := NewDir("")
if err := dir.read(srcDir, ""); err != nil {
if err := dir.read("src", ""); err != nil {
return err
}
// Sort content
dir.sort()
// Manipulate content
if err := manipulate(dir); err != nil {
if err := dir.manipulate(cfg); 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 {
if err := dir.write("dst", outputGemini, cfg); err != nil {
return err
}
if cfg.toHTML {
if err := write(dir, htmlDst, outputHTML); err != nil {
if toHTML {
if err := dir.write("html", outputHTML, cfg); err != nil {
return err
}
}
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.
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) {
path = filepath.Join(p.Permalink, indexPath)
content = p.content
// outputGemini outputs the page as Gemini text.
func outputGemini(p *Page, cfg *Config) (path string, content []byte) {
path = p.Path
if strings.HasSuffix(path, "/") {
path += "index.gmi"
} else {
path += "/index.gmi"
}
content = []byte(p.Content)
return
}
func outputHTML(p *Page) (path string, content []byte) {
const indexPath = "index.html"
path = filepath.Join(p.Permalink, indexPath)
// outputHTML outputs the page as HTML.
func outputHTML(p *Page, cfg *Config) (path string, content []byte) {
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)
content = []byte(textToHTML(text))
content = textToHTML(text)
type tmplCtx struct {
Title string
Content string
// html template context
type htmlCtx struct {
Title string // page title
Content string // page HTML contents
}
var b bytes.Buffer
tmpl := templates[htmlTmpl]
tmpl.Execute(&b, &tmplCtx{
tmpl := cfg.Templates.FindTemplate(pathpkg.Dir(path), "output.html")
tmpl.Execute(&b, &htmlCtx{
Title: p.Title,
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
// TODO: Use go:embed
import (
"io/ioutil"
"os"
"path/filepath"
"strings"
"text/template"
)
// Default atom feed template
const atom_xml = `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>{{ .Title }}</title>
<link href="{{ .URL }}"/>
<link rel="self" href="{{ .URL }}{{ .Path }}"/>
<updated>{{ .Updated.Format "2006-01-02T15:04:05Z07:00" }}</updated>
<id>{{ .URL }}{{ .Path }}</id>
{{- $url := .URL -}}
{{- range .Entries }}
<entry>
<title>{{ .Title }}</title>
<link href="{{ $url }}{{ .Permalink }}"/>
<id>{{ $url }}{{ .Permalink }}</id>
<updated>{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}</updated>
<content src="{{ $url }}{{ .Permalink }}" type="text/gemini"></content>
</entry>
// Templates contains site templates.
type Templates struct {
tmpls map[string]*template.Template
funcs template.FuncMap
}
// NewTemplates returns a new Templates with the default templates.
func NewTemplates() *Templates {
t := &Templates{
tmpls: map[string]*template.Template{},
}
// Load default templates
t.LoadTemplate("/index.gmi", index_gmi)
t.LoadTemplate("/page.gmi", page_gmi)
t.LoadTemplate("/feed.gmi", feed_gmi)
t.LoadTemplate("/output.html", output_html)
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 -}}
</feed>`
// Default directory index template
const index_gmi = `# Index of {{ .Permalink }}
{{ range .Dirs }}=> {{ .Permalink }}
{{ end -}}
{{ range .Pages }}=> {{ .Permalink }}
{{ range .Pages }}=> {{ .Path }}
{{ end -}}`
// Default page template
@ -35,6 +88,14 @@ const page_gmi = `# {{ .Title }}
{{ .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
const output_html = `<!DOCTYPE html>
<meta charset="utf-8">