diff --git a/html.go b/html.go new file mode 100644 index 0000000..cb51851 --- /dev/null +++ b/html.go @@ -0,0 +1,76 @@ +package main + +import ( + "bytes" + "fmt" + "html" + + "git.sr.ht/~adnano/gmi" +) + +// gmiToHTML converts the provided Gemini text to HTML. +func gmiToHTML(text gmi.Text) []byte { + var b bytes.Buffer + var pre bool + var list bool + for _, l := range text { + if _, ok := l.(gmi.LineListItem); ok { + if !list { + list = true + fmt.Fprint(&b, "
\n") + } else { + fmt.Fprint(&b, "\n") + } + case gmi.LinePreformattedText: + text := string(l.(gmi.LinePreformattedText)) + fmt.Fprintf(&b, "%s\n", html.EscapeString(text)) + case gmi.LineHeading1: + text := string(l.(gmi.LineHeading1)) + fmt.Fprintf(&b, "
%s\n", html.EscapeString(text)) + case gmi.LineText: + text := string(l.(gmi.LineText)) + if text == "" { + fmt.Fprint(&b, "
%s
\n", html.EscapeString(text)) + } + } + } + if pre { + fmt.Fprint(&b, "\n") + } + if list { + fmt.Fprint(&b, "\n") + } + return b.Bytes() +} diff --git a/kiln.go b/kiln.go deleted file mode 100644 index 5d68f98..0000000 --- a/kiln.go +++ /dev/null @@ -1,421 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "html" - "io/ioutil" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "text/template" - "time" - - "git.sr.ht/~adnano/gmi" -) - -// Site represents a kiln site. -type Site struct { - Title string // Site title. - URL string // Site URL. - dir *Dir // Site directory. - tmpl *template.Template // Templates. - feeds bool // Whether or not to generate feeds. -} - -// load loads the Site from the current directory. -func (s *Site) load() error { - // Load content - const srcDir = "src" - s.dir = NewDir("") - if err := site.dir.Read(srcDir, ""); err != nil { - return err - } - // Load templates - const tmplDir = "templates" - s.tmpl = template.New(tmplDir) - // Add default templates - s.tmpl.AddParseTree("atom.xml", atomTmpl.Tree) - s.tmpl.AddParseTree("index.gmi", indexTmpl.Tree) - s.tmpl.AddParseTree("page.gmi", pageTmpl.Tree) - // Add function to get site - s.tmpl = s.tmpl.Funcs(template.FuncMap{ - "site": func() *Site { return s }, - }) - var err error - s.tmpl, err = s.tmpl.ParseGlob(filepath.Join(tmplDir, "*.gmi")) - if err != nil { - return err - } - return nil -} - -// write writes the contents of the Index to the provided destination directory. -func (s *Site) write(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 s.dir.Write(dstDir, format) -} - -// manipulate processes and manipulates the site's content. -func (s *Site) manipulate() error { - // FIXME: manipulate doesn't descend into subdirectories - // Write the directory index file, if it doesn't exist - const indexPath = "index.gmi" - if s.dir.index == nil { - var b bytes.Buffer - tmpl := s.tmpl.Lookup(indexPath) - if err := tmpl.Execute(&b, s.dir); err != nil { - return err - } - s.dir.index = &Page{ - Permalink: s.dir.Permalink, - content: b.Bytes(), - } - } - // Manipulate pages - const pagePath = "page.gmi" - for i := range s.dir.Pages { - var b bytes.Buffer - tmpl := s.tmpl.Lookup(pagePath) - if err := tmpl.Execute(&b, s.dir.Pages[i]); err != nil { - return err - } - s.dir.Pages[i].content = b.Bytes() - } - return nil -} - -// sort sorts the site's pages by date. -func (s *Site) sort() { - s.dir.Sort() -} - -// Feed represents a 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 (s *Site) createFeeds() error { - const atomPath = "atom.xml" - var b bytes.Buffer - tmpl := s.tmpl.Lookup(atomPath) - feed := &Feed{ - Title: s.Title, - Path: filepath.Join(s.dir.Permalink, atomPath), - URL: s.URL, - Updated: time.Now(), - Entries: s.dir.Pages, - } - if err := tmpl.Execute(&b, feed); err != nil { - return err - } - path := filepath.Join(s.dir.Permalink, atomPath) - s.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?") - -// NewPage returns a new Page with the given path and content. -func NewPage(path string, content []byte) *Page { - // Try to parse the date from the page filename - var date time.Time - 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 { - 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 - var title string - if submatches := titleRE.FindSubmatch(content); submatches != nil { - title = string(submatches[1]) - // Remove the title from the contents - content = content[len(submatches[0]):] - } - - permalink := strings.TrimSuffix(path, ".gmi") - - return &Page{ - Permalink: "/" + permalink + "/", - Title: title, - Date: date, - content: content, - } -} - -// 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 - } - - switch filepath.Ext(name) { - case ".gmi": - // Gather page data - page := NewPage(path, content) - if name == "index.gmi" { - d.index = page - } else { - d.Pages = append(d.Pages, page) - } - - default: - // 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 files - 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) - -func OutputGemini(p *Page) (path string, content []byte) { - const indexPath = "index.gmi" - path = filepath.Join(p.Permalink, indexPath) - content = p.content - return -} - -func OutputHTML(p *Page) (path string, content []byte) { - const indexPath = "index.html" - const meta = ` - - - -\n") - } else { - fmt.Fprint(&b, "\n") - } - case gmi.LinePreformattedText: - text := string(l.(gmi.LinePreformattedText)) - fmt.Fprintf(&b, "%s\n", html.EscapeString(text)) - case gmi.LineHeading1: - text := string(l.(gmi.LineHeading1)) - fmt.Fprintf(&b, "
%s\n", html.EscapeString(text)) - case gmi.LineText: - text := string(l.(gmi.LineText)) - if text == "" { - fmt.Fprint(&b, "
%s
\n", html.EscapeString(text)) - } - } - } - if pre { - fmt.Fprint(&b, "\n") - } - if list { - fmt.Fprint(&b, "\n") - } - content = b.Bytes() - return -} diff --git a/main.go b/main.go index 56c7248..493c59d 100644 --- a/main.go +++ b/main.go @@ -1,58 +1,132 @@ package main import ( + "bytes" "flag" + "io/ioutil" "log" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "text/template" "time" "git.sr.ht/~adnano/gmi" ) -var ( - serveSite bool - toHtml bool - toAtom bool - site Site +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 + serveSite bool // serve the site +} + func init() { - flag.BoolVar(&serveSite, "serve", false, "serve the site") - flag.BoolVar(&toHtml, "html", false, "output HTML") - flag.BoolVar(&toAtom, "atom", false, "output Atom feed") - flag.StringVar(&site.Title, "title", "", "site title") - flag.StringVar(&site.URL, "url", "", "site URL") + flag.StringVar(&cfg.title, "title", "", "site title") + flag.StringVar(&cfg.url, "url", "", "site URL") + flag.BoolVar(&cfg.toAtom, "atom", false, "output Atom feed") + flag.BoolVar(&cfg.toHTML, "html", false, "output HTML") + flag.BoolVar(&cfg.serveSite, "serve", false, "serve the site") } func main() { flag.Parse() - if err := build(); err != nil { + if err := run(); err != nil { log.Fatal(err) } - - if serveSite { + if cfg.serveSite { serve() } } -// build the site -func build() error { - if err := site.load(); err != nil { +// 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 +} + +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 + }) + // Load content + dir := NewDir("") + if err := dir.read(srcDir, ""); err != nil { return err } - site.sort() - if err := site.manipulate(); err != nil { + // Sort content + dir.sort() + // Manipulate content + if err := manipulate(dir); err != nil { return err } - if toAtom { - if err := site.createFeeds(); err != nil { + if cfg.toAtom { + if err := createFeeds(dir); err != nil { return err } } - if err := site.write("dst", OutputGemini); err != nil { + // Write content + if err := write(dir, dstDir, outputGemini); err != nil { return err } - if toHtml { - if err := site.write("dst.html", OutputHTML); err != nil { + if cfg.toHTML { + if err := write(dir, htmlDst, outputHTML); err != nil { return err } } @@ -70,3 +144,304 @@ func serve() error { server.Handler = gmi.FileServer(gmi.Dir("dst")) return server.ListenAndServe() } + +// 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 := templates[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 := templates[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?") + +// NewPage returns a new Page with the given path and content. +func NewPage(path string, content []byte) *Page { + // Try to parse the date from the page filename + var date time.Time + 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 { + 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 + var title string + if submatches := titleRE.FindSubmatch(content); submatches != nil { + title = string(submatches[1]) + // Remove the title from the contents + content = content[len(submatches[0]):] + } + + permalink := strings.TrimSuffix(path, ".gmi") + return &Page{ + Permalink: "/" + permalink + "/", + Title: title, + Date: date, + content: content, + } +} + +// 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) + +func outputGemini(p *Page) (path string, content []byte) { + path = filepath.Join(p.Permalink, indexPath) + content = p.content + return +} + +func outputHTML(p *Page) (path string, content []byte) { + const indexPath = "index.html" + path = filepath.Join(p.Permalink, indexPath) + + r := bytes.NewReader(p.content) + text := gmi.Parse(r) + content = gmiToHTML(text) + + type tmplCtx struct { + Title string + Content string + } + + var b bytes.Buffer + tmpl := templates[htmlTmpl] + tmpl.Execute(&b, &tmplCtx{ + Title: p.Title, + Content: string(content), + }) + content = b.Bytes() + return +} diff --git a/templates.go b/templates.go index 62398c1..ccb3687 100644 --- a/templates.go +++ b/templates.go @@ -1,9 +1,6 @@ package main -import "text/template" - -// Default templates - +// Default atom feed template const atom_xml = `