package main import ( "bytes" "flag" "io/ioutil" "log" "os" "path/filepath" "regexp" "sort" "strings" "text/template" "time" "git.sr.ht/~adnano/go-gemini" ) 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.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 := run(); err != nil { log.Fatal(err) } if cfg.serveSite { serve() } } // 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 }) // Load content dir := NewDir("") if err := dir.read(srcDir, ""); err != nil { return err } // Sort content dir.sort() // Manipulate content if err := manipulate(dir); 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 { return err } if cfg.toHTML { if err := write(dir, htmlDst, outputHTML); err != nil { return err } } return nil } // serve the site func serve() error { var server gemini.Server server.Certificates.Load("/var/lib/gemini/certs") server.Register("localhost", gemini.FileServer(gemini.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 := 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?") // 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 := gemini.ParseText(r) content = []byte(textToHTML(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 }