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 }