render .md files under root as pages (x.md => /x)
11 files changed, 249 insertions(+), 160 deletions(-)
M internal/builder/builder.go → internal/builder/builder.go
@@ -95,9 +95,9 @@ return nil, errors.WithMessage(err, "could not copy static files") } log.Debug("reading posts", "source", options.Source) - pc, err := content.NewPostsCollection(&content.Config{ - Root: options.Source, - InputDir: "post", + cc, err := content.NewContentCollection(&content.Config{ + Root: options.Source, + PostDir: "post", }, log.Named("content")) if err != nil { return nil, err@@ -105,11 +105,11 @@ } sitemap := sitemap.New(config) lastMod := time.Now() - if len(pc.Posts) > 0 { - lastMod = pc.Posts[0].Date + if len(cc.Posts) > 0 { + lastMod = cc.Posts[0].Date } - for _, post := range pc.Posts { + for _, post := range cc.Posts { log.Debug("rendering post", "post", post.Basename) sitemap.AddPath(post.URL, post.Date) buf.Reset()@@ -124,7 +124,7 @@ } log.Debug("rendering tags list") buf.Reset() - if err := templates.TagsPage(config, "tags", mapset.Sorted(pc.Tags), "/tags").Render(ctx, buf); err != nil { + if err := templates.TagsPage(config, "tags", mapset.Sorted(cc.Tags), "/tags").Render(ctx, buf); err != nil { return nil, err } if err := storage.Write("/tags/", buf); err != nil {@@ -132,9 +132,9 @@ return nil, err } sitemap.AddPath("/tags/", lastMod) - for _, tag := range pc.Tags.ToSlice() { + for _, tag := range cc.Tags.ToSlice() { matchingPosts := []content.Post{} - for _, post := range pc.Posts { + for _, post := range cc.Posts { if slices.Contains(post.Taxonomies.Tags, tag) { matchingPosts = append(matchingPosts, post) }@@ -171,7 +171,7 @@ } log.Debug("rendering list page") buf.Reset() - if err := templates.ListPage(config, pc.Posts, "/post").Render(ctx, buf); err != nil { + if err := templates.ListPage(config, cc.Posts, "/post").Render(ctx, buf); err != nil { return nil, err } if err := storage.Write("/post/", buf); err != nil {@@ -180,7 +180,7 @@ } sitemap.AddPath("/post/", lastMod) log.Debug("rendering feed") - feed, err := renderFeed(config.Title, config, pc.Posts, "feed") + feed, err := renderFeed(config.Title, config, cc.Posts, "feed") if err != nil { return nil, errors.WithMessage(err, "could not render feed") }@@ -214,17 +214,34 @@ return nil, err } r.Hashes = append(r.Hashes, h) - log.Debug("rendering homepage") - post, err := pc.GetPost("index.md") + pages, err := filepath.Glob(joinSource("*.md")) if err != nil { - return nil, err + return nil, errors.WithMessage(err, "could not glob pagess") } - buf.Reset() - if err := templates.Homepage(config, pc.Posts, post).Render(ctx, buf); err != nil { - return nil, err - } - if err := storage.Write("/", buf); err != nil { - return nil, err + for _, p := range pages { + page, err := filepath.Rel(options.Source, p) + if err != nil { + return nil, err + } + post, err := cc.GetPage(page) + if err != nil { + return nil, err + } + + buf.Reset() + log.Debug("rendering page", "source", page, "output", post.Output) + if page == "index.md" { + if err := templates.Homepage(config, cc.Posts, post).Render(ctx, buf); err != nil { + return nil, err + } + } else { + if err := templates.Page(config, post).Render(ctx, buf); err != nil { + return nil, err + } + } + if err := storage.Write(post.Output, buf); err != nil { + return nil, err + } } // it would be nice to set LastMod here, but using the latest post
M internal/content/posts.go → internal/content/posts.go
@@ -42,11 +42,11 @@ content []byte } type Config struct { - Root string - InputDir string + Root string + PostDir string } -type PostsCollection struct { +type Collection struct { config *Config Posts []Post@@ -65,42 +65,79 @@ &fences.Extender{}, ), ) -var outputReplacer = strings.NewReplacer( +var postOutputReplacer = strings.NewReplacer( "index.md", "index.html", ".md", "/index.html", ) -var urlReplacer = strings.NewReplacer( +var postURLReplacer = strings.NewReplacer( "index.md", "", ".md", "/", ) -func (pc *PostsCollection) GetPost(filename string) (*Post, error) { - fp := filepath.Join(pc.config.Root, filename) - url := path.Join("/", urlReplacer.Replace(filename)) + "/" +func (cc *Collection) GetPost(filename string) (*Post, error) { + fp := filepath.Join(cc.config.Root, cc.config.PostDir, filename) + url := path.Join("/", cc.config.PostDir, postURLReplacer.Replace(filename)) + "/" + post := &Post{ + Input: fp, + Output: path.Join(cc.config.PostDir, postOutputReplacer.Replace(filename)), + Basename: filepath.Base(url), + URL: url, + PostMatter: &PostMatter{}, + } + + err := parse(fp, post) + if err != nil { + return nil, err + } + + return post, nil +} + +var pageOutputReplacer = strings.NewReplacer( + "index.md", "index.html", + ".md", ".html", +) +var pageURLReplacer = strings.NewReplacer( + "index.md", "", + ".md", "", +) + +func (cc *Collection) GetPage(filename string) (*Post, error) { + fp := filepath.Join(cc.config.Root, filename) + url := path.Join("/", pageURLReplacer.Replace(filename)) post := &Post{ Input: fp, - Output: path.Join(pc.config.InputDir, outputReplacer.Replace(filename)), + Output: pageOutputReplacer.Replace(filename), Basename: filepath.Base(url), URL: url, PostMatter: &PostMatter{}, } + err := parse(fp, post) + if err != nil { + return nil, err + } + + return post, nil +} + +func parse(fp string, post *Post) error { content, err := os.Open(fp) if err != nil { - return nil, errors.WithMessagef(err, "could not open post %s", fp) + return errors.WithMessagef(err, "could not open post %s", fp) } defer content.Close() post.content, err = frontmatter.Parse(content, post.PostMatter) if err != nil { - return nil, errors.WithMessagef( + return errors.WithMessagef( err, "could not parse front matter of post %s", fp, ) } - return post, nil + return nil } // implements templ.Component@@ -117,15 +154,15 @@ return buf.String(), nil } -func NewPostsCollection(config *Config, log *log.Logger) (*PostsCollection, error) { - pc := &PostsCollection{ +func NewContentCollection(config *Config, log *log.Logger) (*Collection, error) { + cc := &Collection{ Posts: []Post{}, Tags: mapset.NewSet[string](), config: config, } - log.Debug("reading posts", "root", config.Root, "input_dir", config.InputDir) - subdir := filepath.Join(config.Root, config.InputDir) + log.Debug("reading posts", "root", config.Root, "input_dir", config.PostDir) + subdir := filepath.Join(config.Root, config.PostDir) files, err := os.ReadDir(subdir) if err != nil { return nil, errors.WithMessagef(err, "could not read post directory %s", subdir)@@ -134,21 +171,21 @@ for _, f := range files { fn := f.Name() if !f.IsDir() && path.Ext(fn) == ".md" { log.Debug("reading post", "post", fn) - post, err := pc.GetPost(filepath.Join(config.InputDir, fn)) + post, err := cc.GetPost(fn) if err != nil { return nil, err } for _, tag := range post.PostMatter.Taxonomies.Tags { - pc.Tags.Add(strings.ToLower(tag)) + cc.Tags.Add(strings.ToLower(tag)) } - pc.Posts = append(pc.Posts, *post) + cc.Posts = append(cc.Posts, *post) } } - slices.SortFunc(pc.Posts, func(a, b Post) int { + slices.SortFunc(cc.Posts, func(a, b Post) int { return b.Date.Compare(a.Date) }) - return pc, nil + return cc, nil }
M internal/storage/files/file.go → internal/storage/files/file.go
@@ -87,8 +87,15 @@ return strings.TrimPrefix(pathname, "/") } +func cutSuffix(s, suffix string) string { + out, _ := strings.CutSuffix(s, suffix) + + return out +} + func fileNameToPathName(filename string) string { - pathname, _ := strings.CutSuffix(filename, "index.html") - - return pathname + return cutSuffix( + cutSuffix(filename, "index.html"), + ".html", + ) }
M internal/storage/files/reader.go → internal/storage/files/reader.go
@@ -78,10 +78,16 @@ } func (r *Reader) CanonicalisePath(path string) (cPath string, differs bool) { cPath = path - if strings.HasSuffix(path, "/index.html") { + switch { + case strings.HasSuffix(path, "/index.html"): cPath, differs = strings.CutSuffix(path, "index.html") - } else if !strings.HasSuffix(path, "/") && r.files[path+"/"] != nil { + + case strings.HasSuffix(path, ".html"): + cPath, differs = strings.CutSuffix(path, ".html") + + case !strings.HasSuffix(path, "/") && r.files[path+"/"] != nil: cPath, differs = path+"/", true + } return cPath, differs
M templates/error.templ → templates/error.templ
@@ -7,7 +7,7 @@ "strconv" ) templ Error(config *config.Config, path string, err *http.Error) { - @Page(config, PageSettings{ + @Layout(config, PageSettings{ Title: "Error", Path: path, }) {
M templates/homepage.templ → templates/homepage.templ
@@ -6,7 +6,7 @@ "go.alanpearce.eu/website/internal/content" ) templ Homepage(config *config.Config, posts []content.Post, content templ.Component) { - @Page(config, PageSettings{ + @Layout(config, PageSettings{ Title: config.Title, TitleAttrs: templ.Attributes{ "class": "p-name u-url",
A templates/layout.templ
@@ -0,0 +1,116 @@ +package templates + +import ( + "context" + "go.alanpearce.eu/website/internal/config" + "io" + "io/fs" + "net/url" +) + +var ( + CSS string +) + +func init() { + bytes, err := fs.ReadFile(Files, "style.css") + if err != nil { + panic(err) + } + CSS = string(bytes) +} + +type PageSettings struct { + Title string + Path string + TitleAttrs templ.Attributes + BodyAttrs templ.Attributes +} + +func extendClasses(cs string, attrs templ.Attributes) string { + if extras, exists := attrs["class"]; exists { + return templ.Classes(cs, extras).String() + } else { + return cs + } +} + +templ menuItem(item config.MenuItem) { + <a + href={ templ.SafeURL(item.URL.String()) } + if item.URL.IsAbs() { + target="_blank" + } + >{ item.Name }</a> +} + +templ Layout(site *config.Config, page PageSettings) { + <!DOCTYPE html> + <html lang={ site.DefaultLanguage }> + <head> + <meta charset="utf-8"/> + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> + <title>{ page.Title }</title> + <link rel="alternate" type="application/atom+xml" title={ site.Title } href="/atom.xml"/> + @style(CSS) + </head> + <body { page.BodyAttrs... }> + <a class="skip" href="#main">Skip to main content</a> + <header> + <h2> + <a href="/" class={ extendClasses("title p-name", page.TitleAttrs) } { page.TitleAttrs... }>{ site.Title }</a> + </h2> + <nav> + for _, item := range site.Menus["main"] { + @menuItem(item) + } + </nav> + </header> + <main id="main"> + { children... } + </main> + <footer> + Content is + <a rel="license" href="http://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>. + <a href="https://git.alanpearce.eu/website/">Site source code</a> is + <a href="https://opensource.org/licenses/MIT">MIT</a> + </footer> + @counter(site, page.Path, page.Title) + if site.InjectLiveReload { + <script defer> + new EventSource("/_/reload").onmessage = event => { + console.log("got message", event) + window.location.reload() + }; + </script> + } + </body> + </html> +} + +func mkURL(original config.URL, path string, title string) string { + ou := *original.URL + u := config.URL{ + URL: &ou, + } + q := url.Values{} + q.Add("p", path) + q.Add("t", title) + u.RawQuery = q.Encode() + + return u.String() +} + +templ counter(config *config.Config, path string, title string) { + <script data-goatcounter={ config.GoatCounter.String() } async src="https://stats.alanpearce.eu/count.v4.js" crossorigin="anonymous" integrity="sha384-nRw6qfbWyJha9LhsOtSb2YJDyZdKvvCFh0fJYlkquSFjUxp9FVNugbfy8q1jdxI+"></script> + <noscript> + <img src={ string(templ.URL(mkURL(config.GoatCounter, path, title))) }/> + </noscript> +} + +func style(css string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + _, err = io.WriteString(w, "<style>\n"+css+"\n</style>") + return + }) +}
M templates/list.templ → templates/list.templ
@@ -6,7 +6,7 @@ "go.alanpearce.eu/website/internal/content" ) templ TagPage(config *config.Config, tag string, posts []content.Post, path string) { - @Page(config, PageSettings{ + @Layout(config, PageSettings{ Title: tag, Path: path, TitleAttrs: templ.Attributes{@@ -25,7 +25,7 @@ } } templ ListPage(config *config.Config, posts []content.Post, path string) { - @Page(config, PageSettings{ + @Layout(config, PageSettings{ Title: config.Title, TitleAttrs: templ.Attributes{ "class": "p-author h-card",
M templates/page.templ → templates/page.templ
@@ -1,116 +1,22 @@ package templates import ( - "context" "go.alanpearce.eu/website/internal/config" - "io" - "io/fs" - "net/url" -) - -var ( - CSS string + "go.alanpearce.eu/website/internal/content" ) -func init() { - bytes, err := fs.ReadFile(Files, "style.css") - if err != nil { - panic(err) - } - CSS = string(bytes) -} - -type PageSettings struct { - Title string - Path string - TitleAttrs templ.Attributes - BodyAttrs templ.Attributes -} - -func extendClasses(cs string, attrs templ.Attributes) string { - if extras, exists := attrs["class"]; exists { - return templ.Classes(cs, extras).String() - } else { - return cs - } -} - -templ menuItem(item config.MenuItem) { - <a - href={ templ.SafeURL(item.URL.String()) } - if item.URL.IsAbs() { - target="_blank" - } - >{ item.Name }</a> -} - -templ Page(site *config.Config, page PageSettings) { - <!DOCTYPE html> - <html lang={ site.DefaultLanguage }> - <head> - <meta charset="utf-8"/> - <meta name="viewport" content="width=device-width, initial-scale=1.0"/> - <title>{ page.Title }</title> - <link rel="alternate" type="application/atom+xml" title={ site.Title } href="/atom.xml"/> - @style(CSS) - </head> - <body { page.BodyAttrs... }> - <a class="skip" href="#main">Skip to main content</a> - <header> - <h2> - <a href="/" class={ extendClasses("title p-name", page.TitleAttrs) } { page.TitleAttrs... }>{ site.Title }</a> - </h2> - <nav> - for _, item := range site.Menus["main"] { - @menuItem(item) - } - </nav> - </header> - <main id="main"> - { children... } - </main> - <footer> - Content is - <a rel="license" href="http://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>. - <a href="https://git.alanpearce.eu/website/">Site source code</a> is - <a href="https://opensource.org/licenses/MIT">MIT</a> - </footer> - @counter(site, page.Path, page.Title) - if site.InjectLiveReload { - <script defer> - new EventSource("/_/reload").onmessage = event => { - console.log("got message", event) - window.location.reload() - }; - </script> - } - </body> - </html> -} - -func mkURL(original config.URL, path string, title string) string { - ou := *original.URL - u := config.URL{ - URL: &ou, +templ Page(config *config.Config, page *content.Post) { + @Layout(config, PageSettings{ + Title: page.Title, + TitleAttrs: templ.Attributes{ + "class": "h-card", + "rel": "author", + }, + Path: page.URL, + }) { + <h1 class="p-name">{ page.Title }</h1> + <div class="content"> + @page + </div> } - q := url.Values{} - q.Add("p", path) - q.Add("t", title) - u.RawQuery = q.Encode() - - return u.String() -} - -templ counter(config *config.Config, path string, title string) { - <script data-goatcounter={ config.GoatCounter.String() } async src="https://stats.alanpearce.eu/count.v4.js" crossorigin="anonymous" integrity="sha384-nRw6qfbWyJha9LhsOtSb2YJDyZdKvvCFh0fJYlkquSFjUxp9FVNugbfy8q1jdxI+"></script> - <noscript> - <img src={ string(templ.URL(mkURL(config.GoatCounter, path, title))) }/> - </noscript> -} - -func style(css string) templ.Component { - return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { - _, err = io.WriteString(w, "<style>\n"+css+"\n</style>") - return - }) }
M templates/post.templ → templates/post.templ
@@ -23,7 +23,7 @@ </time> } templ PostPage(config *config.Config, post content.Post) { - @Page(config, PageSettings{ + @Layout(config, PageSettings{ Title: post.Title, TitleAttrs: templ.Attributes{ "class": "p-author h-card",