diff options
Diffstat (limited to 'internal/builder')
-rw-r--r-- | internal/builder/builder.go | 277 | ||||
-rw-r--r-- | internal/builder/files.go | 120 | ||||
-rw-r--r-- | internal/builder/hasher.go | 13 | ||||
-rw-r--r-- | internal/builder/posts.go | 129 | ||||
-rw-r--r-- | internal/builder/sitemap.go | 29 | ||||
-rw-r--r-- | internal/builder/template.go | 437 |
6 files changed, 355 insertions, 650 deletions
diff --git a/internal/builder/builder.go b/internal/builder/builder.go index b17fbc2..b99d919 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -1,54 +1,80 @@ package builder import ( + "context" "fmt" "io" - "net/url" "os" "path" + "path/filepath" "slices" - "sync" "time" - "website/internal/config" - "website/internal/log" + "go.alanpearce.eu/website/internal/config" + "go.alanpearce.eu/website/internal/content" + "go.alanpearce.eu/x/log" + "go.alanpearce.eu/website/internal/sitemap" + "go.alanpearce.eu/website/templates" - cp "github.com/otiai10/copy" - "github.com/pkg/errors" - "github.com/snabb/sitemap" + "github.com/a-h/templ" + mapset "github.com/deckarep/golang-set/v2" + "gitlab.com/tozd/go/errors" ) type IOConfig struct { Source string `conf:"default:.,short:s,flag:src"` - Destination string `conf:"default:website,short:d,flag:dest"` - BaseURL config.URL - Development bool `conf:"default:false,flag:dev"` + Destination string `conf:"default:public,short:d,flag:dest"` + Development bool `conf:"default:false,flag:dev"` } +type Result struct { + Hashes []string +} + +var compressFiles = false + func mkdirp(dirs ...string) error { err := os.MkdirAll(path.Join(dirs...), 0755) return errors.Wrap(err, "could not create directory") } -func outputToFile(output io.Reader, filename ...string) error { - log.Debug("outputting file", "filename", path.Join(filename...)) - file, err := os.OpenFile(path.Join(filename...), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) +func outputToFile(output io.Reader, pathParts ...string) error { + filename := path.Join(pathParts...) + // log.Debug("outputting file", "filename", filename) + file, err := openFileAndVariants(filename) if err != nil { return errors.WithMessage(err, "could not open output file") } defer file.Close() - if _, err := file.ReadFrom(output); err != nil { + if _, err := io.Copy(file, output); err != nil { return errors.WithMessage(err, "could not write output file") } return nil } -func writerToFile(writer io.WriterTo, filename ...string) error { - log.Debug("outputting file", "filename", path.Join(filename...)) - file, err := os.OpenFile(path.Join(filename...), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) +func renderToFile(component templ.Component, pathParts ...string) error { + filename := path.Join(pathParts...) + // log.Debug("outputting file", "filename", filename) + file, err := openFileAndVariants(filename) + if err != nil { + return errors.WithMessage(err, "could not open output file") + } + defer file.Close() + + if err := component.Render(context.TODO(), file); err != nil { + return errors.WithMessage(err, "could not write output file") + } + + return nil +} + +func writerToFile(writer io.WriterTo, pathParts ...string) error { + filename := path.Join(pathParts...) + // log.Debug("outputting file", "filename", path.Join(filename...)) + file, err := openFileAndVariants(filename) if err != nil { return errors.WithMessage(err, "could not open output file") } @@ -61,206 +87,185 @@ func writerToFile(writer io.WriterTo, filename ...string) error { return nil } -func build(outDir string, config config.Config) error { - log.Debug("output", "dir", outDir) - assetsOnce = sync.Once{} - privateDir := path.Join(outDir, "private") - if err := mkdirp(privateDir); err != nil { - return errors.WithMessage(err, "could not create private directory") +func joinSourcePath(src string) func(string) string { + return func(rel string) string { + return filepath.Join(src, rel) } - publicDir := path.Join(outDir, "public") - if err := mkdirp(publicDir); err != nil { - return errors.WithMessage(err, "could not create public directory") +} + +func build(ioConfig *IOConfig, config *config.Config, log *log.Logger) (*Result, error) { + outDir := ioConfig.Destination + joinSource := joinSourcePath(ioConfig.Source) + log.Debug("output", "dir", outDir) + r := &Result{ + Hashes: make([]string, 0), } - err := cp.Copy("static", publicDir, cp.Options{ - PreserveTimes: true, - PermissionControl: cp.AddPermission(0755), - }) + err := copyRecursive(joinSource("static"), outDir) if err != nil { - return errors.WithMessage(err, "could not copy static files") + return nil, errors.WithMessage(err, "could not copy static files") } - if err := mkdirp(publicDir, "post"); err != nil { - return errors.WithMessage(err, "could not create post output directory") + if err := mkdirp(outDir, "post"); err != nil { + return nil, errors.WithMessage(err, "could not create post output directory") } log.Debug("reading posts") - posts, tags, err := readPosts("content", "post", publicDir) + posts, tags, err := content.ReadPosts(&content.Config{ + Root: joinSource("content"), + InputDir: "post", + OutputDir: outDir, + }, log.Named("content")) if err != nil { - return err + return nil, err } - sm := NewSitemap(config) + sitemap := sitemap.New(config) lastMod := time.Now() if len(posts) > 0 { lastMod = posts[0].Date } for _, post := range posts { - if err := mkdirp(publicDir, "post", post.Basename); err != nil { - return errors.WithMessage(err, "could not create directory for post") + if err := mkdirp(outDir, "post", post.Basename); err != nil { + return nil, errors.WithMessage(err, "could not create directory for post") } log.Debug("rendering post", "post", post.Basename) - sm.Add(&sitemap.URL{ - Loc: post.URL, - LastMod: &post.Date, - }) - output, err := renderPost(post, config) - if err != nil { - return errors.WithMessagef(err, "could not render post %s", post.Input) - } - if err := outputToFile(output, post.Output); err != nil { - return err + sitemap.AddPath(post.URL, post.Date) + if err := renderToFile(templates.PostPage(config, post), post.Output); err != nil { + return nil, err } } - if err := mkdirp(publicDir, "tags"); err != nil { - return errors.WithMessage(err, "could not create directory for tags") + if err := mkdirp(outDir, "tags"); err != nil { + return nil, errors.WithMessage(err, "could not create directory for tags") } log.Debug("rendering tags list") - output, err := renderTags(tags, config, "/tags") - if err != nil { - return errors.WithMessage(err, "could not render tags") + if err := renderToFile( + templates.TagsPage(config, "tags", mapset.Sorted(tags), "/tags"), + outDir, + "tags", + "index.html", + ); err != nil { + return nil, err } - if err := outputToFile(output, publicDir, "tags", "index.html"); err != nil { - return err - } - sm.Add(&sitemap.URL{ - Loc: "/tags/", - LastMod: &lastMod, - }) + sitemap.AddPath("/tags/", lastMod) for _, tag := range tags.ToSlice() { - matchingPosts := []Post{} + matchingPosts := []content.Post{} for _, post := range posts { if slices.Contains(post.Taxonomies.Tags, tag) { matchingPosts = append(matchingPosts, post) } } - if err := mkdirp(publicDir, "tags", tag); err != nil { - return errors.WithMessage(err, "could not create directory") + if err := mkdirp(outDir, "tags", tag); err != nil { + return nil, errors.WithMessage(err, "could not create directory") } log.Debug("rendering tags page", "tag", tag) url := "/tags/" + tag - output, err := renderListPage(tag, config, matchingPosts, url) - if err != nil { - return errors.WithMessage(err, "could not render tag page") - } - if err := outputToFile(output, publicDir, "tags", tag, "index.html"); err != nil { - return err + if err := renderToFile( + templates.TagPage(config, tag, matchingPosts, url), + outDir, + "tags", + tag, + "index.html", + ); err != nil { + return nil, err } - sm.Add(&sitemap.URL{ - Loc: url, - LastMod: &matchingPosts[0].Date, - }) + sitemap.AddPath(url, matchingPosts[0].Date) log.Debug("rendering tags feed", "tag", tag) - output, err = renderFeed( + feed, err := renderFeed( fmt.Sprintf("%s - %s", config.Title, tag), config, matchingPosts, tag, ) if err != nil { - return errors.WithMessage(err, "could not render tag feed page") + return nil, errors.WithMessage(err, "could not render tag feed page") } - if err := outputToFile(output, publicDir, "tags", tag, "atom.xml"); err != nil { - return err + if err := writerToFile(feed, outDir, "tags", tag, "atom.xml"); err != nil { + return nil, err } } log.Debug("rendering list page") - listPage, err := renderListPage("", config, posts, "/post") - if err != nil { - return errors.WithMessage(err, "could not render list page") + if err := renderToFile(templates.ListPage(config, posts, "/post"), outDir, "post", "index.html"); err != nil { + return nil, err } - if err := outputToFile(listPage, publicDir, "post", "index.html"); err != nil { - return err - } - sm.Add(&sitemap.URL{ - Loc: "/post/", - LastMod: &lastMod, - }) + sitemap.AddPath("/post/", lastMod) log.Debug("rendering feed") feed, err := renderFeed(config.Title, config, posts, "feed") if err != nil { - return errors.WithMessage(err, "could not render feed") + return nil, errors.WithMessage(err, "could not render feed") } - if err := outputToFile(feed, publicDir, "atom.xml"); err != nil { - return err + if err := writerToFile(feed, outDir, "atom.xml"); err != nil { + return nil, err } log.Debug("rendering feed styles") - feedStyles, err := renderFeedStyles() + feedStyles, err := renderFeedStyles(ioConfig.Source) + if err != nil { + return nil, errors.WithMessage(err, "could not render feed styles") + } + if err := outputToFile(feedStyles, outDir, "feed-styles.xsl"); err != nil { + return nil, err + } + _, err = feedStyles.Seek(0, 0) if err != nil { - return errors.WithMessage(err, "could not render feed styles") + return nil, err } - if err := outputToFile(feedStyles, publicDir, "feed-styles.xsl"); err != nil { - return err + h, err := getFeedStylesHash(feedStyles) + if err != nil { + return nil, err } + r.Hashes = append(r.Hashes, h) log.Debug("rendering homepage") - homePage, err := renderHomepage(config, posts, "/") + _, text, err := content.GetPost(joinSource(filepath.Join("content", "index.md"))) + if err != nil { + return nil, err + } + content, err := content.RenderMarkdown(text) if err != nil { - return errors.WithMessage(err, "could not render homepage") + return nil, err } - if err := outputToFile(homePage, publicDir, "index.html"); err != nil { - return err + if err := renderToFile(templates.Homepage(config, posts, content), outDir, "index.html"); err != nil { + return nil, err } // it would be nice to set LastMod here, but using the latest post // date would be wrong as the homepage has its own content file // without a date, which could be newer - sm.Add(&sitemap.URL{ - Loc: "/", - }) - - log.Debug("rendering 404 page") - notFound, err := render404(config, "/404.html") - if err != nil { - return errors.WithMessage(err, "could not render 404 page") - } - if err := outputToFile(notFound, publicDir, "404.html"); err != nil { - return err - } + sitemap.AddPath("/", time.Time{}) + h, _ = getHTMLStyleHash(outDir, "index.html") + r.Hashes = append(r.Hashes, h) log.Debug("rendering sitemap") - if err := writerToFile(sm, publicDir, "sitemap.xml"); err != nil { - return err + if err := writerToFile(sitemap, outDir, "sitemap.xml"); err != nil { + return nil, err } log.Debug("rendering robots.txt") - rob, err := renderRobotsTXT(config) + rob, err := renderRobotsTXT(ioConfig.Source, config) if err != nil { - return err + return nil, err } - if err := outputToFile(rob, publicDir, "robots.txt"); err != nil { - return err + if err := outputToFile(rob, outDir, "robots.txt"); err != nil { + return nil, err } - return nil + return r, nil } -func BuildSite(ioConfig IOConfig) error { - config, err := config.GetConfig() - if err != nil { - return errors.WithMessage(err, "could not get config") +func BuildSite(ioConfig *IOConfig, cfg *config.Config, log *log.Logger) (*Result, error) { + if cfg == nil { + return nil, errors.New("config is nil") } - config.InjectLiveReload = ioConfig.Development + cfg.InjectLiveReload = ioConfig.Development + compressFiles = !ioConfig.Development - if ioConfig.BaseURL.URL != nil { - config.BaseURL.URL, err = url.Parse(ioConfig.BaseURL.String()) - if err != nil { - return errors.WithMessage(err, "could not re-parse base URL") - } - } - - if ioConfig.Development && ioConfig.Destination != "website" { - err = os.RemoveAll(ioConfig.Destination) - if err != nil { - return errors.WithMessage(err, "could not remove destination directory") - } - } + templates.Setup() + loadCSS(ioConfig.Source) - return build(ioConfig.Destination, *config) + return build(ioConfig, cfg, log) } diff --git a/internal/builder/files.go b/internal/builder/files.go new file mode 100644 index 0000000..a9046d7 --- /dev/null +++ b/internal/builder/files.go @@ -0,0 +1,120 @@ +package builder + +import ( + "compress/gzip" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/andybalholm/brotli" +) + +const ( + gzipLevel = 6 + brotliLevel = 9 +) + +type MultiWriteCloser struct { + writers []io.WriteCloser + multiWriter io.Writer +} + +func (mw *MultiWriteCloser) Write(p []byte) (n int, err error) { + return mw.multiWriter.Write(p) +} + +func (mw *MultiWriteCloser) Close() error { + var lastErr error + for _, w := range mw.writers { + err := w.Close() + if err != nil { + lastErr = err + } + } + + return lastErr +} + +func openFileWrite(filename string) (*os.File, error) { + return os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) +} + +func openFileGz(filename string) (*gzip.Writer, error) { + filenameGz := filename + ".gz" + f, err := openFileWrite(filenameGz) + if err != nil { + return nil, err + } + + return gzip.NewWriterLevel(f, gzipLevel) +} + +func openFileBrotli(filename string) (*brotli.Writer, error) { + filenameBrotli := filename + ".br" + f, err := openFileWrite(filenameBrotli) + if err != nil { + return nil, err + } + + return brotli.NewWriterLevel(f, brotliLevel), nil +} + +func multiOpenFile(filename string) (*MultiWriteCloser, error) { + r, err := openFileWrite(filename) + if err != nil { + return nil, err + } + gz, err := openFileGz(filename) + if err != nil { + return nil, err + } + br, err := openFileBrotli(filename) + if err != nil { + return nil, err + } + + return &MultiWriteCloser{ + writers: []io.WriteCloser{r, gz, br}, + multiWriter: io.MultiWriter(r, gz, br), + }, nil +} + +func openFileAndVariants(filename string) (io.WriteCloser, error) { + if compressFiles { + return multiOpenFile(filename) + } + + return openFileWrite(filename) +} + +func copyRecursive(src, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if d.IsDir() { + return mkdirp(dst, rel) + } + + sf, err := os.Open(path) + if err != nil { + return err + } + defer sf.Close() + df, err := openFileAndVariants(filepath.Join(dst, rel)) + if err != nil { + return err + } + defer df.Close() + if _, err := io.Copy(df, sf); err != nil { + return err + } + + return nil + }) +} diff --git a/internal/builder/hasher.go b/internal/builder/hasher.go new file mode 100644 index 0000000..f0f9167 --- /dev/null +++ b/internal/builder/hasher.go @@ -0,0 +1,13 @@ +package builder + +import ( + "crypto/sha256" + "encoding/base64" +) + +func hash(s string) string { + shasum := sha256.New() + shasum.Write([]byte(s)) + + return "sha256-" + base64.StdEncoding.EncodeToString(shasum.Sum(nil)) +} diff --git a/internal/builder/posts.go b/internal/builder/posts.go deleted file mode 100644 index deae3e8..0000000 --- a/internal/builder/posts.go +++ /dev/null @@ -1,129 +0,0 @@ -package builder - -import ( - "bytes" - "os" - "path" - "path/filepath" - "slices" - "strings" - "time" - "website/internal/log" - - "github.com/adrg/frontmatter" - mapset "github.com/deckarep/golang-set/v2" - "github.com/pkg/errors" - fences "github.com/stefanfritsch/goldmark-fences" - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/extension" - htmlrenderer "github.com/yuin/goldmark/renderer/html" -) - -type PostMatter struct { - Date time.Time `toml:"date"` - Description string `toml:"description"` - Title string `toml:"title"` - Taxonomies struct { - Tags []string `toml:"tags"` - } `toml:"taxonomies"` -} - -type Post struct { - Input string - Output string - Basename string - URL string - Content string - PostMatter -} - -type Tags mapset.Set[string] - -var markdown = goldmark.New( - goldmark.WithRendererOptions( - htmlrenderer.WithUnsafe(), - ), - goldmark.WithExtensions( - extension.GFM, - extension.Footnote, - extension.Typographer, - &fences.Extender{}, - ), -) - -func getPost(filename string) (*PostMatter, []byte, error) { - matter := PostMatter{} - content, err := os.Open(filename) - if err != nil { - return nil, nil, errors.WithMessagef(err, "could not open post %s", filename) - } - defer content.Close() - rest, err := frontmatter.MustParse(content, &matter) - if err != nil { - return nil, nil, errors.WithMessagef( - err, - "could not parse front matter of post %s", - filename, - ) - } - - return &matter, rest, nil -} - -func renderMarkdown(content []byte) (string, error) { - var buf bytes.Buffer - if err := markdown.Convert(content, &buf); err != nil { - return "", errors.WithMessage(err, "could not convert markdown content") - } - - return buf.String(), nil -} - -func readPosts(root string, inputDir string, outputDir string) ([]Post, Tags, error) { - tags := mapset.NewSet[string]() - posts := []Post{} - subdir := filepath.Join(root, inputDir) - files, err := os.ReadDir(subdir) - if err != nil { - return nil, nil, errors.WithMessagef(err, "could not read post directory %s", subdir) - } - outputReplacer := strings.NewReplacer(root, outputDir, ".md", "/index.html") - urlReplacer := strings.NewReplacer(root, "", ".md", "/") - for _, f := range files { - pathFromRoot := filepath.Join(subdir, f.Name()) - if !f.IsDir() && path.Ext(pathFromRoot) == ".md" { - output := outputReplacer.Replace(pathFromRoot) - url := urlReplacer.Replace(pathFromRoot) - log.Debug("reading post", "post", pathFromRoot) - matter, content, err := getPost(pathFromRoot) - if err != nil { - return nil, nil, err - } - - for _, tag := range matter.Taxonomies.Tags { - tags.Add(strings.ToLower(tag)) - } - - log.Debug("rendering markdown in post", "post", pathFromRoot) - html, err := renderMarkdown(content) - if err != nil { - return nil, nil, err - } - post := Post{ - Input: pathFromRoot, - Output: output, - Basename: filepath.Base(url), - URL: url, - PostMatter: *matter, - Content: html, - } - - posts = append(posts, post) - } - } - slices.SortFunc(posts, func(a, b Post) int { - return b.Date.Compare(a.Date) - }) - - return posts, tags, nil -} diff --git a/internal/builder/sitemap.go b/internal/builder/sitemap.go deleted file mode 100644 index 81e3a31..0000000 --- a/internal/builder/sitemap.go +++ /dev/null @@ -1,29 +0,0 @@ -package builder - -import ( - "io" - "website/internal/config" - - "github.com/snabb/sitemap" -) - -type Sitemap struct { - config *config.Config - Sitemap *sitemap.Sitemap -} - -func NewSitemap(cfg config.Config) *Sitemap { - return &Sitemap{ - config: &cfg, - Sitemap: sitemap.New(), - } -} - -func (s *Sitemap) Add(u *sitemap.URL) { - u.Loc = s.config.BaseURL.JoinPath(u.Loc).String() - s.Sitemap.Add(u) -} - -func (s *Sitemap) WriteTo(w io.Writer) (int64, error) { - return s.Sitemap.WriteTo(w) -} diff --git a/internal/builder/template.go b/internal/builder/template.go index ab36c85..9f019df 100644 --- a/internal/builder/template.go +++ b/internal/builder/template.go @@ -1,55 +1,41 @@ package builder import ( + "bytes" "encoding/xml" - "fmt" "io" - "net/url" "os" + "path/filepath" "strings" - "sync" "text/template" - "time" - "website/internal/atom" - "website/internal/config" - "website/internal/log" + + "go.alanpearce.eu/website/internal/atom" + "go.alanpearce.eu/website/internal/config" + "go.alanpearce.eu/website/internal/content" "github.com/PuerkitoBio/goquery" - "github.com/a-h/htmlformat" "github.com/antchfx/xmlquery" "github.com/antchfx/xpath" - mapset "github.com/deckarep/golang-set/v2" - "github.com/pkg/errors" - "golang.org/x/net/html" + "gitlab.com/tozd/go/errors" ) var ( - assetsOnce sync.Once - css string - countHTML *goquery.Document - liveReloadHTML *goquery.Document - templates = make(map[string]*os.File) + css string + nsMap = map[string]string{ + "xsl": "http://www.w3.org/1999/XSL/Transform", + "atom": "http://www.w3.org/2005/Atom", + "xhtml": "http://www.w3.org/1999/xhtml", + } ) -func loadTemplate(path string) (file *os.File, err error) { - if templates[path] == nil { - file, err = os.OpenFile(path, os.O_RDONLY, 0) - if err != nil { - return nil, errors.Wrapf(err, "could not load template at path %s", path) - } - templates[path] = file +func loadCSS(source string) { + bytes, err := os.ReadFile(filepath.Join(source, "templates/style.css")) + if err != nil { + panic(err) } - file = templates[path] - - return + css = string(bytes) } -var ( - imgOnce sync.Once - img *goquery.Selection - urlTemplate *url.URL -) - type QuerySelection struct { *goquery.Selection } @@ -68,237 +54,9 @@ func (q *QueryDocument) Find(selector string) *QuerySelection { return &QuerySelection{q.Document.Find(selector)} } -func NewDocumentNoScript(r io.Reader) (*goquery.Document, error) { - root, err := html.ParseWithOptions(r, html.ParseOptionEnableScripting(false)) - - return goquery.NewDocumentFromNode(root), errors.Wrap(err, "could not parse HTML") -} - -func (root QuerySelection) setImgURL(pageURL string, pageTitle string) QuerySelection { - clone := countHTML.Clone() - imgOnce.Do(func() { - var err error - img = clone.Find("img") - attr, _ := img.Attr("src") - if attr == "" { - panic("<img> does not have src attribute") - } - urlTemplate, err = url.Parse(attr) - if err != nil { - panic(err.Error()) - } - }) - q := urlTemplate.Query() - urlTemplate.RawQuery = "" - q.Set("p", pageURL) - q.Set("t", pageTitle) - output := urlTemplate.String() + "?" + q.Encode() - clone.Find("img").SetAttr("src", output) - root.AppendSelection(clone.Find("body").Children()) - - return root -} - -func layout( - filename string, - config config.Config, - pageTitle string, - pageURL string, -) (*goquery.Document, error) { - html, err := loadTemplate(filename) - if err != nil { - return nil, err - } - defer func() { - _, err := html.Seek(0, io.SeekStart) - if err != nil { - panic("could not reset template file offset: " + err.Error()) - } - }() - assetsOnce.Do(func() { - var bytes []byte - bytes, err = os.ReadFile("templates/style.css") - if err != nil { - return - } - css = string(bytes) - countFile, err := os.OpenFile("templates/count.html", os.O_RDONLY, 0) - if err != nil { - return - } - defer countFile.Close() - countHTML, err = NewDocumentNoScript(countFile) - if err != nil { - return - } - if config.InjectLiveReload { - liveReloadFile, err := os.OpenFile("templates/dev.html", os.O_RDONLY, 0) - if err != nil { - return - } - defer liveReloadFile.Close() - liveReloadHTML, err = goquery.NewDocumentFromReader(liveReloadFile) - if err != nil { - return - } - } - }) - if err != nil { - return nil, errors.Wrap(err, "could not set up layout template") - } - - doc, err := NewDocumentFromReader(html) - if err != nil { - return nil, err - } - doc.Find("html").SetAttr("lang", config.DefaultLanguage) - doc.Find("head > link[rel=alternate]").SetAttr("title", config.Title) - doc.Find("head > link[rel=canonical]").SetAttr("href", pageURL) - doc.Find(".title").SetText(config.Title) - doc.Find("title").Add(".p-name").SetText(pageTitle) - doc.Find("head > style").SetHtml(css) - doc.Find("body").setImgURL(pageURL, pageTitle) - if config.InjectLiveReload { - doc.Find("body").AppendSelection(liveReloadHTML.Find("body").Clone()) - } - nav := doc.Find("nav") - navLink := doc.Find("nav a") - nav.Empty() - for _, link := range config.Menus["main"] { - nav.AppendSelection(navLink.Clone().SetAttr("href", link.URL).SetText(link.Name)) - } - - return doc.Document, nil -} - -func renderPost(post Post, config config.Config) (r io.Reader, err error) { - doc, err := layout("templates/post.html", config, post.PostMatter.Title, post.URL) - if err != nil { - return nil, err - } - doc.Find(".title").AddClass("p-author h-card").SetAttr("rel", "author") - doc.Find(".h-entry .dt-published"). - SetAttr("datetime", post.PostMatter.Date.UTC().Format(time.RFC3339)). - SetText( - post.PostMatter.Date.Format("2006-01-02"), - ) - doc.Find(".h-entry .e-content").SetHtml(post.Content) - categories := doc.Find(".h-entry .p-categories") - tpl := categories.Find(".p-category").ParentsUntilSelection(categories) - tpl.Remove() - for _, tag := range post.Taxonomies.Tags { - cat := tpl.Clone() - cat.Find(".p-category").SetAttr("href", fmt.Sprintf("/tags/%s/", tag)).SetText("#" + tag) - categories.AppendSelection(cat) - } - - return renderHTML(doc), nil -} - -func renderTags(tags Tags, config config.Config, url string) (io.Reader, error) { - doc, err := layout("templates/tags.html", config, config.Title, url) - if err != nil { - return nil, err - } - tagList := doc.Find(".tags") - tpl := doc.Find(".h-feed") - tpl.Remove() - for _, tag := range mapset.Sorted(tags) { - li := tpl.Clone() - li.Find("a").SetAttr("href", fmt.Sprintf("/tags/%s/", tag)).SetText("#" + tag) - tagList.AppendSelection(li) - } - - return renderHTML(doc), nil -} - -func renderListPage(tag string, config config.Config, posts []Post, url string) (io.Reader, error) { - var title string - if len(tag) > 0 { - title = tag - } else { - title = config.Title - } - doc, err := layout("templates/list.html", config, title, url) - if err != nil { - return nil, err - } - feed := doc.Find(".h-feed") - tpl := feed.Find(".h-entry") - tpl.Remove() - - doc.Find(".title").AddClass("p-author h-card").SetAttr("rel", "author") - if tag == "" { - doc.Find(".filter").Remove() - } else { - doc.Find(".filter").Find("h3").SetText("#" + tag) - } - - for _, post := range posts { - entry := tpl.Clone() - entry.Find(".p-name").SetText(post.Title).SetAttr("href", post.URL) - entry.Find(".dt-published"). - SetAttr("datetime", post.PostMatter.Date.UTC().Format(time.RFC3339)). - SetText(post.PostMatter.Date.Format("2006-01-02")) - feed.AppendSelection(entry) - } - - return renderHTML(doc), nil -} - -func renderHomepage(config config.Config, posts []Post, url string) (io.Reader, error) { - _, index, err := getPost("content/_index.md") - if err != nil { - return nil, err - } - doc, err := layout("templates/homepage.html", config, config.Title, url) - if err != nil { - return nil, err - } - doc.Find("body").AddClass("h-card") - doc.Find(".title").AddClass("p-name u-url") - - html, err := renderMarkdown(index) - if err != nil { - return nil, err - } - doc.Find("#content").SetHtml(html) - - feed := doc.Find(".h-feed") - tpl := feed.Find(".h-entry") - tpl.Remove() - - for _, post := range posts[0:3] { - entry := tpl.Clone() - entry.Find(".p-name").SetText(post.Title) - entry.Find(".u-url").SetAttr("href", post.URL) - entry. - Find(".dt-published"). - SetAttr("datetime", post.PostMatter.Date.UTC().Format(time.RFC3339)). - SetText(post.PostMatter.Date.Format("2006-01-02")) - - feed.AppendSelection(entry) - } - doc.Find(".u-email"). - SetAttr("href", fmt.Sprintf("mailto:%s", config.Email)). - SetText(config.Email) - - elsewhere := doc.Find(".elsewhere") - linkRelMe := elsewhere.Find(".u-url[rel=me]").ParentsUntil("ul") - linkRelMe.Remove() - - for _, link := range config.Menus["me"] { - el := linkRelMe.Clone() - el.Find("a").SetAttr("href", link.URL).SetText(link.Name) - elsewhere.AppendSelection(el) - } - - return renderHTML(doc), nil -} - -func renderRobotsTXT(config config.Config) (io.Reader, error) { +func renderRobotsTXT(source string, config *config.Config) (io.Reader, error) { r, w := io.Pipe() - tpl, err := template.ParseFiles("templates/robots.tmpl") + tpl, err := template.ParseFiles(filepath.Join(source, "templates/robots.tmpl")) if err != nil { return nil, err } @@ -311,55 +69,36 @@ func renderRobotsTXT(config config.Config) (io.Reader, error) { } w.Close() }() - return r, nil -} -func render404(config config.Config, url string) (io.Reader, error) { - doc, err := layout("templates/404.html", config, "404 Not Found", url) - if err != nil { - return nil, err - } - - return renderHTML(doc), nil + return r, nil } func renderFeed( title string, - config config.Config, - posts []Post, + config *config.Config, + posts []content.Post, specific string, -) (io.Reader, error) { - reader, err := loadTemplate("templates/feed.xml") +) (io.WriterTo, error) { + buf := &bytes.Buffer{} + datetime := posts[0].Date.UTC() + + buf.WriteString(xml.Header) + err := atom.LinkXSL(buf, "/feed-styles.xsl") if err != nil { return nil, err } - defer func() { - _, err := reader.Seek(0, io.SeekStart) - if err != nil { - panic("could not reset reader: " + err.Error()) - } - }() - doc, err := xmlquery.Parse(reader) - if err != nil { - return nil, errors.Wrap(err, "could not parse XML") - } - feed := doc.SelectElement("feed") - feed.SelectElement("title").FirstChild.Data = title - feed.SelectElement("link").SetAttr("href", config.BaseURL.String()) - feed.SelectElement("id").FirstChild.Data = atom.MakeTagURI(config, specific) - datetime, err := posts[0].Date.UTC().MarshalText() - if err != nil { - return nil, errors.Wrap(err, "could not convert post date to text") + feed := &atom.Feed{ + Title: title, + Link: atom.MakeLink(config.BaseURL.URL), + ID: atom.MakeTagURI(config, specific), + Updated: datetime, + Entries: make([]*atom.FeedEntry, len(posts)), } - feed.SelectElement("updated").FirstChild.Data = string(datetime) - tpl := feed.SelectElement("entry") - xmlquery.RemoveFromTree(tpl) - for _, post := range posts { - fullURL := config.BaseURL.JoinPath(post.URL).String() - text, err := xml.MarshalIndent(&atom.FeedEntry{ + for i, post := range posts { + feed.Entries[i] = &atom.FeedEntry{ Title: post.Title, - Link: atom.MakeLink(fullURL), + Link: atom.MakeLink(config.BaseURL.JoinPath(post.URL)), ID: atom.MakeTagURI(config, post.Basename), Updated: post.Date.UTC(), Summary: post.Description, @@ -368,80 +107,66 @@ func renderFeed( Content: post.Content, Type: "html", }, - }, " ", " ") - if err != nil { - return nil, errors.Wrap(err, "could not marshal xml") - } - entry, err := xmlquery.ParseWithOptions( - strings.NewReader(string(text)), - xmlquery.ParserOptions{ - Decoder: &xmlquery.DecoderOptions{ - Strict: false, - AutoClose: xml.HTMLAutoClose, - Entity: xml.HTMLEntity, - }, - }, - ) - if err != nil { - return nil, errors.Wrap(err, "could not parse XML") } - xmlquery.AddChild(feed, entry.SelectElement("entry")) + } + enc := xml.NewEncoder(buf) + err = enc.Encode(feed) + if err != nil { + return nil, err } - return strings.NewReader(doc.OutputXML(true)), nil + return buf, nil } -func renderFeedStyles() (io.Reader, error) { - reader, err := loadTemplate("templates/feed-styles.xsl") +func renderFeedStyles(source string) (*strings.Reader, error) { + tpl, err := template.ParseFiles(filepath.Join(source, "templates/feed-styles.xsl")) if err != nil { return nil, err } - defer func() { - _, err := reader.Seek(0, io.SeekStart) - if err != nil { - panic("could not reset reader: " + err.Error()) - } - }() - nsMap := map[string]string{ - "xsl": "http://www.w3.org/1999/XSL/Transform", - "atom": "http://www.w3.org/2005/Atom", - "xhtml": "http://www.w3.org/1999/xhtml", + + esc := &strings.Builder{} + err = xml.EscapeText(esc, []byte(css)) + if err != nil { + return nil, err } - doc, err := xmlquery.Parse(reader) + + w := &strings.Builder{} + err = tpl.Execute(w, map[string]interface{}{ + "css": esc.String(), + }) + if err != nil { + return nil, err + } + + return strings.NewReader(w.String()), nil +} + +func getFeedStylesHash(r io.Reader) (string, error) { + doc, err := xmlquery.Parse(r) if err != nil { - return nil, errors.Wrap(err, "could not parse XML") + return "", err } expr, err := xpath.CompileWithNS("//xhtml:style", nsMap) if err != nil { - return nil, errors.Wrap(err, "could not parse XML") + return "", errors.Wrap(err, "could not parse XPath") } style := xmlquery.QuerySelector(doc, expr) - xmlquery.AddChild(style, &xmlquery.Node{ - Type: xmlquery.TextNode, - Data: css, - }) - return strings.NewReader(doc.OutputXML(true)), nil + return hash(style.InnerText()), nil } -func renderHTML(doc *goquery.Document) io.Reader { - r, w := io.Pipe() - - go func() { - _, err := w.Write([]byte("<!doctype html>\n")) - if err != nil { - log.Error("error writing doctype", "error", err) - w.CloseWithError(err) - } - err = htmlformat.Nodes(w, []*html.Node{doc.Children().Get(0)}) - if err != nil { - log.Error("error rendering html", "error", err) - w.CloseWithError(err) - - return - } - defer w.Close() - }() +func getHTMLStyleHash(filenames ...string) (string, error) { + fn := filepath.Join(filenames...) + f, err := os.Open(fn) + if err != nil { + return "", err + } + defer f.Close() + doc, err := NewDocumentFromReader(f) + if err != nil { + return "", err + } + html := doc.Find("head > style").Text() - return r + return hash(html), nil } |