package main import ( "bytes" "encoding/json" "encoding/xml" "fmt" "io" "io/fs" "log" "log/slog" "net/url" "os" "os/exec" "path" "path/filepath" "slices" "strings" "time" . "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", "/") markdown := 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) slog.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)) } var buf bytes.Buffer slog.Debug("rendering markdown in post", "post", pathFromRoot) if err := markdown.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 } type purgeCSSOutput struct { CSS string `json:"css"` File string } var purgedCSS map[string]string func purgeCSS(htmlFilename string, cssFilename string) (string, error) { if purgedCSS == nil { purgedCSS = make(map[string]string) } else if purgedCSS[htmlFilename] == "" { slog.Debug("running purgecss", "html", htmlFilename, "css", cssFilename) bytes, err := exec.Command("bun", "./node_modules/.bin/purgecss", "--css", cssFilename, "--content", htmlFilename).Output() if err != nil { return "", errors.WithMessage(err, "failed running `purgecss` command") } var out []purgeCSSOutput err = json.Unmarshal(bytes, &out) if err != nil { return "", errors.WithMessage(err, "failed decoding `purgecss` output") } purgedCSS[htmlFilename] = out[0].CSS } return purgedCSS[htmlFilename], 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 := purgeCSS(filename, "templates/style.css") if err != nil { bytes, err := os.ReadFile("templates/style.css") if err != nil { return nil, err } css = string(bytes) } 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("\n" + 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 renderHTML(doc *goquery.Document) io.Reader { r, w := io.Pipe() go func() { goquery.Render(w, doc.Children()) defer w.Close() }() return r } func renderPost(post Post, config Config) (r io.Reader, err error) { doc, err := layout("templates/post.html", config, post.PostMatter.Title) 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) (io.Reader, error) { doc, err := layout("templates/tags.html", config, config.Title) 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, posts []Post) (io.Reader, 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 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, posts []Post) (io.Reader, error) { _, index, err := getPost("content/_index.md") if err != nil { return nil, err } doc, err := layout("templates/homepage.html", config, config.Title) if err != nil { return nil, 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 nil, 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 renderHTML(doc), nil } func render404(config Config) (io.Reader, error) { doc, err := layout("templates/404.html", config, "404 Not Found") if err != nil { return nil, err } return renderHTML(doc), nil } func renderFeed(title string, config Config, posts []Post, specific string) (io.Reader, error) { reader, err := os.Open("templates/feed.xml") if err != nil { return nil, 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 nil, 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 nil, 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 nil, err } xmlquery.AddChild(feed, entry.SelectElement("entry")) } return strings.NewReader(doc.OutputXML(true)), nil } func renderFeedStyles() (io.Reader, error) { reader, err := os.Open("templates/feed-styles.xsl") if err != nil { return nil, 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 nil, err } style := xmlquery.QuerySelector(doc, expr) css, err := os.ReadFile("templates/style.css") if err != nil { return nil, err } xmlquery.AddChild(style, &xmlquery.Node{ Type: xmlquery.TextNode, Data: string(css), }) return strings.NewReader(doc.OutputXML(true)), nil } func mkdirp(dirs ...string) error { return os.MkdirAll(path.Join(dirs...), 0755) } func outputToFile(output io.Reader, filename ...string) error { file, err := os.OpenFile(path.Join(filename...), os.O_WRONLY|os.O_CREATE, 00644) if err != nil { return errors.WithMessage(err, "could not open output file") } defer file.Close() if _, err := file.ReadFrom(output); err != nil { return errors.WithMessage(err, "could not write output file") } return nil } func build() error { config, err := GetConfig() if err != nil { return err } outDir := "new" if err := mkdirp(outDir, "post"); err != nil { return errors.WithMessage(err, "could not create post output directory") } slog.Debug("reading posts") posts, tags, err := readPosts("content", "post", outDir) if err != nil { return err } for _, post := range posts { if err := mkdirp(outDir, "post", post.Basename); err != nil { return errors.WithMessage(err, "could not create directory for post") } slog.Debug("rendering post", "post", post.Basename) 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 } } if err := mkdirp(outDir, "tags"); err != nil { return errors.WithMessage(err, "could not create directory for tags") } slog.Debug("rendering tags list") output, err := renderTags(tags, *config) if err != nil { return errors.WithMessage(err, "could not render tags") } if err := outputToFile(output, outDir, "tags", "index.html"); err != nil { return err } for _, tag := range tags.ToSlice() { matchingPosts := []Post{} for _, post := range posts { if slices.Contains(post.Taxonomies.Tags, tag) { matchingPosts = append(matchingPosts, post) } } if err := mkdirp(outDir, "tags", tag); err != nil { return errors.WithMessage(err, "could not create directory") } slog.Debug("rendering tags page", "tag", tag) output, err := renderListPage(tag, *config, matchingPosts) if err != nil { return errors.WithMessage(err, "could not render tag page") } if err := outputToFile(output, outDir, "tags", tag, "index.html"); err != nil { return err } slog.Debug("rendering tags feed", "tag", tag) output, 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 := outputToFile(output, outDir, "tags", tag, "atom.xml"); err != nil { return err } } slog.Debug("rendering list page") listPage, err := renderListPage("", *config, posts) if err != nil { return errors.WithMessage(err, "could not render list page") } if err := outputToFile(listPage, outDir, "post", "index.html"); err != nil { return err } slog.Debug("rendering feed") feed, err := renderFeed(config.Title, *config, posts, "feed") if err != nil { return errors.WithMessage(err, "could not render feed") } if err := outputToFile(feed, outDir, "atom.xml"); err != nil { return err } slog.Debug("rendering feed styles") feedStyles, err := renderFeedStyles() if err != nil { return errors.WithMessage(err, "could not render feed styles") } if err := outputToFile(feedStyles, outDir, "feed-styles.xsl"); err != nil { return err } slog.Debug("rendering homepage") homePage, err := renderHomepage(*config, posts) if err != nil { return errors.WithMessage(err, "could not render homepage") } if err := outputToFile(homePage, outDir, "index.html"); err != nil { return err } slog.Debug("rendering 404 page") notFound, err := render404(*config) if err != nil { return errors.WithMessage(err, "could not render 404 page") } if err := outputToFile(notFound, outDir, "404.html"); err != nil { return err } return nil } func main() { if os.Getenv("DEBUG") != "" { 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)) } 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") }