diff --git a/Makefile b/Makefile index 151035a..abf4760 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md index f86da40..2aad378 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.go b/config.go new file mode 100644 index 0000000..fe1ca02 --- /dev/null +++ b/config.go @@ -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) +} diff --git a/dir.go b/dir.go new file mode 100644 index 0000000..6535335 --- /dev/null +++ b/dir.go @@ -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() + } +} diff --git a/doc/kiln.1.scd b/doc/kiln.1.scd index d8f4702..8457b64 100644 --- a/doc/kiln.1.scd +++ b/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. diff --git a/html.go b/html.go index 77673c8..220a2c9 100644 --- a/html.go +++ b/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, "
\n") + b.WriteString("\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, "\n") } else { - fmt.Fprint(&b, "\n") + b.WriteString("
%s
\n", html.EscapeString(text)) } } } if pre { - fmt.Fprint(&b, "\n") + b.WriteString("\n") } if list { - fmt.Fprint(&b, "\n") + b.WriteString("\n") } - return b.String() + return b.Bytes() } diff --git a/main.go b/main.go index 185c47b..ac33cf5 100644 --- a/main.go +++ b/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), }) diff --git a/page.go b/page.go new file mode 100644 index 0000000..f64a4ea --- /dev/null +++ b/page.go @@ -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 +} diff --git a/templates.go b/templates.go index 9f5e317..a4022e2 100644 --- a/templates.go +++ b/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 = ` -