package builder import ( "encoding/xml" "fmt" "io" "net/url" "os" "path/filepath" "strings" "sync" "text/template" "time" "website/internal/atom" "website/internal/config" "website/internal/log" "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" ) var ( assetsOnce sync.Once css string countHTML *goquery.Document liveReloadHTML *goquery.Document templates = make(map[string]*os.File) 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 } file = templates[path] return } var ( imgOnce sync.Once img *goquery.Selection urlTemplate *url.URL ) type QuerySelection struct { *goquery.Selection } type QueryDocument struct { *goquery.Document } func NewDocumentFromReader(r io.Reader) (*QueryDocument, error) { doc, err := goquery.NewDocumentFromReader(r) return &QueryDocument{doc}, errors.Wrap(err, "could not create query document") } 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(" 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) { r, w := io.Pipe() tpl, err := template.ParseFiles("templates/robots.tmpl") if err != nil { return nil, err } go func() { err = tpl.Execute(w, map[string]interface{}{ "BaseURL": config.BaseURL, }) if err != nil { w.CloseWithError(err) } 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 } func renderFeed( title string, config config.Config, posts []Post, specific string, ) (io.Reader, error) { reader, err := loadTemplate("templates/feed.xml") 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.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{ Title: post.Title, Link: atom.MakeLink(fullURL), ID: atom.MakeTagURI(config, post.Basename), Updated: post.Date.UTC(), Summary: post.Description, Author: config.Title, Content: atom.FeedContent{ 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")) } return strings.NewReader(doc.OutputXML(true)), nil } func renderFeedStyles() (*strings.Reader, error) { reader, err := loadTemplate("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()) } }() doc, err := xmlquery.Parse(reader) if err != nil { return nil, errors.Wrap(err, "could not parse XML") } expr, err := xpath.CompileWithNS("//xhtml:style", nsMap) if err != nil { return nil, errors.Wrap(err, "could not parse XML") } style := xmlquery.QuerySelector(doc, expr) xmlquery.AddChild(style, &xmlquery.Node{ Type: xmlquery.TextNode, Data: css, }) return strings.NewReader(doc.OutputXML(true)), nil } func getFeedStylesHash(r *strings.Reader) (string, error) { doc, err := xmlquery.Parse(r) if err != nil { return "", err } expr, err := xpath.CompileWithNS("//xhtml:style", nsMap) if err != nil { return "", errors.Wrap(err, "could not parse XPath") } style := xmlquery.QuerySelector(doc, expr) return hash(style.InnerText()), nil } 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 hash(html), nil } func renderHTML(doc *goquery.Document) io.Reader { r, w := io.Pipe() go func() { _, err := w.Write([]byte("\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() }() return r }