package main import ( "bytes" "encoding/xml" "fmt" "io/fs" "log" "log/slog" "net/url" "os" "path" "path/filepath" "slices" "strings" "time" . "alanpearce.eu/website/internal/config" "github.com/BurntSushi/toml" "github.com/PuerkitoBio/goquery" "github.com/adrg/frontmatter" "github.com/antchfx/xmlquery" "github.com/antchfx/xpath" mapset "github.com/deckarep/golang-set/v2" "github.com/pkg/errors" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "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] func getPost(filename string) (*PostMatter, *[]byte, error) { matter := PostMatter{} content, err := os.Open(filename) defer content.Close() if err != nil { return nil, nil, errors.WithMessagef(err, "could not open post %s", filename) } 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 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", "/") md := goldmark.New( goldmark.WithRendererOptions( html.WithUnsafe(), ), goldmark.WithExtensions( extension.GFM, extension.Footnote, extension.Typographer, ), ) 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) matter, content, err := getPost(pathFromRoot) if err != nil { return nil, nil, err } for _, tag := range matter.Taxonomies.Tags { tags.Add(strings.ToLower(tag)) } var buf bytes.Buffer if err := md.Convert(*content, &buf); err != nil { return nil, nil, errors.WithMessage(err, "could not convert markdown content") } post := Post{ Input: pathFromRoot, Output: output, Basename: filepath.Base(url), URL: url, PostMatter: *matter, Content: buf.String(), } posts = append(posts, post) } } slices.SortFunc(posts, func(a, b Post) int { return b.Date.Compare(a.Date) }) return posts, tags, nil } func layout(filename string, config Config, pageTitle string) (*goquery.Document, error) { html, err := os.Open(filename) if err != nil { return nil, err } defer html.Close() css, err := os.ReadFile("templates/style.css") if err != nil { return nil, err } doc, err := goquery.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(".title").SetText(config.Title) doc.Find("title").Add(".p-name").SetText(pageTitle) doc.Find("head > style").SetHtml(string(css)) 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, nil } func renderPost(post Post, config Config) (string, error) { doc, err := layout("templates/post.html", config, post.PostMatter.Title) if err != nil { return "", 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 doc.Html() } func renderTags(tags Tags, config Config) (string, error) { doc, err := layout("templates/tags.html", config, config.Title) if err != nil { return "", 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 doc.Html() } func renderListPage(tag string, config Config, posts []Post) (string, error) { var title string if len(tag) > 0 { title = tag } else { title = config.Title } doc, err := layout("templates/list.html", config, title) if err != nil { return "", 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 doc.Html() } func renderHomepage(config Config, posts []Post) (string, error) { _, index, err := getPost("content/_index.md") if err != nil { return "", err } doc, err := layout("templates/homepage.html", config, config.Title) if err != nil { return "", err } doc.Find("body").AddClass("h-card") doc.Find(".title").AddClass("p-name u-url") var buf bytes.Buffer md := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe())) if err := md.Convert(*index, &buf); err != nil { return "", err } doc.Find("#content").SetHtml(buf.String()) 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 doc.Html() } func render404(config Config) (string, error) { doc, err := layout("templates/404.html", config, "404 Not Found") if err != nil { return "", err } return doc.Html() } func renderFeed(title string, config Config, posts []Post, specific string) (string, error) { reader, err := os.Open("templates/feed.xml") if err != nil { return "", err } defer reader.Close() doc, err := xmlquery.Parse(reader) feed := doc.SelectElement("feed") feed.SelectElement("title").FirstChild.Data = title feed.SelectElement("link").SetAttr("href", config.BaseURL) feed.SelectElement("id").FirstChild.Data = MakeTagURI(config, specific) datetime, err := posts[0].Date.UTC().MarshalText() feed.SelectElement("updated").FirstChild.Data = string(datetime) tpl := feed.SelectElement("entry") xmlquery.RemoveFromTree(tpl) for _, post := range posts { fullURL, err := url.JoinPath(config.BaseURL, post.URL) if err != nil { return "", err } text, err := xml.MarshalIndent(&FeedEntry{ Title: post.Title, Link: MakeLink(fullURL), Id: MakeTagURI(config, post.Basename), Updated: post.Date.UTC(), Summary: post.Description, Author: config.Title, Content: FeedContent{ Content: post.Content, Type: "html", }, }, " ", " ") if err != nil { return "", err } 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 "", err } xmlquery.AddChild(feed, entry.SelectElement("entry")) } return doc.OutputXML(true), nil } func renderFeedStyles() (string, error) { reader, err := os.Open("templates/feed-styles.xsl") if err != nil { return "", err } defer reader.Close() 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", } doc, err := xmlquery.Parse(reader) expr, err := xpath.CompileWithNS("//xhtml:style", nsMap) if err != nil { return "", err } style := xmlquery.QuerySelector(doc, expr) css, err := os.ReadFile("templates/style.css") if err != nil { return "", err } xmlquery.AddChild(style, &xmlquery.Node{ Type: xmlquery.TextNode, Data: string(css), }) return doc.OutputXML(true), nil } type errFiler struct { err error } func build() error { config, err := GetConfig() if err != nil { return err } outDir := "new" if err := os.MkdirAll(path.Join(outDir, "post"), 0755); err != nil { return errors.WithMessage(err, "could not create post output directory") } posts, tags, err := readPosts("content", "post", outDir) if err != nil { return err } for _, post := range posts { dst := path.Join(outDir, "post", post.Basename) if err := os.MkdirAll(dst, 0755); err != nil { return errors.WithMessagef(err, "could not create directory for post %s", dst) } output, err := renderPost(post, *config) if err != nil { return errors.WithMessagef(err, "could not render post %s", post.Input) } if err := os.WriteFile(post.Output, []byte(output), 0755); err != nil { return errors.WithMessage(err, "could not write output file") } } if err := os.MkdirAll(path.Join(outDir, "tags"), 0755); err != nil { return errors.WithMessage(err, "could not create directory for tags") } tagsHtml, err := renderTags(tags, *config) if err != nil { return errors.WithMessage(err, "could not render tags") } err = os.WriteFile(path.Join(outDir, "tags", "index.html"), []byte(tagsHtml), 0644) if err != nil { return errors.WithMessage(err, "could not write output file") } for _, tag := range tags.ToSlice() { matchingPosts := []Post{} for _, post := range posts { if slices.Contains(post.Taxonomies.Tags, tag) { matchingPosts = append(matchingPosts, post) } } if err := os.MkdirAll(path.Join(outDir, "tags", tag), 0755); err != nil { return errors.WithMessage(err, "could not create directory") } tagPage, err := renderListPage(tag, *config, matchingPosts) if err != nil { return errors.WithMessage(err, "could not render tag page") } if err := os.WriteFile(path.Join(outDir, "tags", tag, "index.html"), []byte(tagPage), 0644); err != nil { return errors.WithMessage(err, "could not write tag output file") } feedPage, 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") } if err := os.WriteFile(path.Join(outDir, "tags", tag, "atom.xml"), []byte(feedPage), 0644); err != nil { return errors.WithMessage(err, "could not write tag feed output file") } } listPage, err := renderListPage("", *config, posts) if err != nil { return errors.WithMessage(err, "could not render list page") } if err := os.WriteFile(path.Join(outDir, "post", "index.html"), []byte(listPage), 0644); err != nil { return errors.WithMessage(err, "could not write list page output file") } feed, err := renderFeed(config.Title, *config, posts, "feed") if err != nil { return errors.WithMessage(err, "could not render feed") } if err := os.WriteFile(path.Join(outDir, "atom.xml"), []byte(feed), 0644); err != nil { return errors.WithMessage(err, "could not write feed") } feedStyles, err := renderFeedStyles() if err != nil { return errors.WithMessage(err, "could not render feed styles") } if err := os.WriteFile(path.Join(outDir, "feed-styles.xsl"), []byte(feedStyles), 0644); err != nil { return errors.WithMessage(err, "could not write feed styles") } homePage, err := renderHomepage(*config, posts) if err != nil { return errors.WithMessage(err, "could not render homepage") } if err := os.WriteFile(path.Join(outDir, "index.html"), []byte(homePage), 0644); err != nil { return errors.WithMessage(err, "could not write homepage") } notFound, err := render404(*config) if err != nil { return errors.WithMessage(err, "could not render 404 page") } if err := os.WriteFile(path.Join(outDir, "404.html"), []byte(notFound), 0644); err != nil { return errors.WithMessage(err, "could not write 404 file") } return nil } func main() { if true { slog.SetLogLoggerLevel(slog.LevelDebug) } slog.Debug("starting build process") _, err := os.Getwd() if err != nil { log.Panic(errors.Errorf("working directory does not exist: %v", err)) } // log.SetFlags(log.Lshortfile) if err := build(); err != nil { switch cause := errors.Cause(err).(type) { case *fs.PathError: slog.Info("pathError") slog.Error(fmt.Sprintf("%s", err)) case toml.ParseError: slog.Info("parseError") slog.Error(fmt.Sprintf("%s", err)) default: slog.Info("other") slog.Error(fmt.Sprintf("cause:%+v", errors.Cause(cause))) slog.Error(fmt.Sprintf("%+v", cause)) } os.Exit(1) } slog.Debug("done") }