mirror of
https://git.sr.ht/~adnano/kiln
synced 2024-11-23 18:51:10 +00:00
Add support for Gemini feeds
This commit is contained in:
parent
3c4d934f55
commit
f14999d88a
7
Makefile
7
Makefile
|
@ -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
|
||||
|
|
|
@ -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
62
config.go
Normal 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
201
dir.go
Normal 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()
|
||||
}
|
||||
}
|
125
doc/kiln.1.scd
125
doc/kiln.1.scd
|
@ -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
22
html.go
|
@ -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
486
main.go
|
@ -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
62
page.go
Normal 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
|
||||
}
|
113
templates.go
113
templates.go
|
@ -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">
|
||||
|
|
Loading…
Reference in a new issue