penguin/flatgit

flatgit showcase

commit c152a522ff4215c0dcc176118c4997f7f79fe770

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-06-27T01:08:16Z
subjectRenders README and markdown files
commit c152a522ff4215c0dcc176118c4997f7f79fe770
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-06-27T01:08:16Z

    Renders README and markdown files
---
 go.mod                              | 11 ++++++
 go.sum                              | 10 +++++
 internal/buildinfo/buildinfo_gen.go |  4 +-
 internal/render/markdown.go         | 51 ++++++++++++++++++++++++
 internal/render/render.go           | 78 ++++++++++++++++++++++++++++++++-----
 internal/render/rtree.go            | 11 ++++++
 internal/render/templates.go        | 17 +++++++-
 7 files changed, 170 insertions(+), 12 deletions(-)

diff --git a/go.mod b/go.mod
index 12611ab..b824ee2 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,14 @@
 module github.com/tgckpg/flatgit
 
 go 1.25
+
+require (
+	github.com/microcosm-cc/bluemonday v1.0.27
+	github.com/yuin/goldmark v1.8.2
+)
+
+require (
+	github.com/aymerick/douceur v0.2.0 // indirect
+	github.com/gorilla/css v1.0.1 // indirect
+	golang.org/x/net v0.26.0 // indirect
+)
diff --git a/go.sum b/go.sum
index e69de29..884e507 100644
--- a/go.sum
+++ b/go.sum
@@ -0,0 +1,10 @@
+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
+github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
+github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
+golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
diff --git a/internal/buildinfo/buildinfo_gen.go b/internal/buildinfo/buildinfo_gen.go
index 61f6d7a..9cc9af0 100644
--- a/internal/buildinfo/buildinfo_gen.go
+++ b/internal/buildinfo/buildinfo_gen.go
@@ -2,6 +2,6 @@ package buildinfo
 
 const (
     Version     = "dev"
-    GitRevision = "e7920416df1fd5a5ec6ec046a931cef5232b08af"
-    Timestamp   = "20260624.053038"
+    GitRevision = "5ce6c3b70b12fb55cf0e108c7eabc51d5c50ae43"
+    Timestamp   = "20260627.005304"
 )
diff --git a/internal/render/markdown.go b/internal/render/markdown.go
new file mode 100644
index 0000000..343eb29
--- /dev/null
+++ b/internal/render/markdown.go
@@ -0,0 +1,51 @@
+package render
+
+import (
+	"bytes"
+	"path/filepath"
+	"strings"
+
+	"github.com/microcosm-cc/bluemonday"
+	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/extension"
+	"github.com/yuin/goldmark/parser"
+)
+
+func renderMarkdownSafe(src []byte) ([]byte, error) {
+	md := goldmark.New(
+		goldmark.WithExtensions(extension.GFM),
+		goldmark.WithParserOptions(parser.WithAutoHeadingID()),
+	)
+
+	var buf bytes.Buffer
+	if err := md.Convert(src, &buf); err != nil {
+		return nil, err
+	}
+
+	policy := bluemonday.UGCPolicy()
+	return policy.SanitizeBytes(buf.Bytes()), nil
+}
+
+func isMarkdownPath(path string) bool {
+	name := strings.ToLower(filepath.Base(path))
+	ext := strings.ToLower(filepath.Ext(path))
+
+	return ext == ".md" || name == "readme"
+}
+
+func findReadmePath(tree []TreeEntry) string {
+	for _, e := range tree {
+		if e.Type != "blob" {
+			continue
+		}
+
+		name := strings.ToLower(e.Path)
+
+		switch name {
+		case "readme.md", "readme.markdown", "readme":
+			return e.Path
+		}
+	}
+
+	return ""
+}
diff --git a/internal/render/render.go b/internal/render/render.go
index c634bf3..6b70cac 100644
--- a/internal/render/render.go
+++ b/internal/render/render.go
@@ -114,14 +114,27 @@ func (r *Renderer) RenderRepo(ctx context.Context, repo config.Repo) error {
 		GeneratedAt:  manifest.GeneratedAt.Format(time.RFC3339),
 	}
 
+	readmeHTML, err := r.renderReadmeHTML(ctx, repo, commit, tree)
+	if err != nil {
+		return err
+	}
+
 	if err := renderTemplate(filepath.Join(next, "index.html"), indexTemplate, struct {
 		basePage
-		Refs    []RefInfo
-		Commits []CommitInfo
-		Files   []TreeEntry
-	}{page, refs, firstCommits(commits, 20), firstTree(tree, 50)}); err != nil {
+		Refs       []RefInfo
+		Commits    []CommitInfo
+		Files      []TreeEntry
+		ReadmeHTML template.HTML
+	}{
+		page,
+		refs,
+		firstCommits(commits, 3),
+		firstTree(tree, 50),
+		readmeHTML,
+	}); err != nil {
 		return err
 	}
+
 	if err := renderTemplate(filepath.Join(next, "refs.html"), refsTemplate, struct {
 		basePage
 		Refs []RefInfo
@@ -195,6 +208,51 @@ func (r *Renderer) resolveDefaultCommit(ctx context.Context, repo config.Repo) (
 	return "", last
 }
 
+func (r *Renderer) renderReadmeHTML(
+	ctx context.Context,
+	repo config.Repo,
+	commit string,
+	tree []TreeEntry,
+) (template.HTML, error) {
+	readmePath := findReadmePath(tree)
+	if readmePath == "" {
+		return "", nil
+	}
+
+	content, err := r.blob(ctx, repo, commit, readmePath)
+	if err != nil {
+		return "", err
+	}
+
+	htmlBytes, err := renderMarkdownSafe(content)
+	if err != nil {
+		return "", err
+	}
+
+	return template.HTML(htmlBytes), nil
+}
+
+func (r *Renderer) blob(
+	ctx context.Context,
+	repo config.Repo,
+	commit string,
+	path string,
+) ([]byte, error) {
+	spec := fmt.Sprintf("%s:%s", commit, path)
+
+	content, err := r.Git.Output(
+		ctx,
+		repo.MirrorDir,
+		"show",
+		spec,
+	)
+	if err != nil {
+		return nil, fmt.Errorf("read blob %s at %s: %w", path, commit, err)
+	}
+
+	return content, nil
+}
+
 func (r *Renderer) refs(ctx context.Context, repo config.Repo) ([]RefInfo, error) {
 	out, err := r.Git.Output(ctx, repo.MirrorDir, "for-each-ref", "--format=%(refname)%00%(objectname)", "refs/heads", "refs/tags")
 	if err != nil {
@@ -283,11 +341,13 @@ type basePage struct {
 }
 
 type blobView struct {
-	Path    string
-	Size    int64
-	RawHref string
-	Text    string
-	Binary  bool
+	Path             string
+	Size             int64
+	RawHref          string
+	Text             string
+	Binary           bool
+	HTML             template.HTML
+	RenderedMarkdown bool
 }
 
 func writeStatic(root string) error {
diff --git a/internal/render/rtree.go b/internal/render/rtree.go
index c53206b..f65074b 100644
--- a/internal/render/rtree.go
+++ b/internal/render/rtree.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"html/template"
 	"io"
 	"io/fs"
 	"os"
@@ -168,6 +169,16 @@ func (r *Renderer) renderBlobEntry(
 		blob.Text = string(content)
 	}
 
+	if !blob.Binary && isMarkdownPath(blob.Path) {
+		htmlBytes, err := renderMarkdownSafe(content)
+		if err != nil {
+			return err
+		}
+
+		blob.HTML = template.HTML(htmlBytes)
+		blob.RenderedMarkdown = true
+	}
+
 	if err := renderTemplate(blobPath, blobTemplate, struct {
 		basePage
 		Blob blobView
diff --git a/internal/render/templates.go b/internal/render/templates.go
index 3e7a6d9..e86b876 100644
--- a/internal/render/templates.go
+++ b/internal/render/templates.go
@@ -58,6 +58,13 @@ const indexTemplate = `{{define "pageTitle"}}{{.RepoManifest.Repository.FullName
 <tr><td class="mono"><a href="{{$.RepoManifest.Repository.SitePath}}blob/{{$.RepoManifest.Repository.DefaultRefSlug}}/{{.Path}}.html">{{.Path}}</a></td><td>{{.Size}}</td></tr>
 {{end}}
 </table>
+
+{{if .ReadmeHTML}}
+<section class="readme">
+  {{.ReadmeHTML}}
+</section>
+{{end}}
+
 {{end}}
 `
 
@@ -119,7 +126,15 @@ const blobTemplate = `{{define "pageTitle"}}{{.RepoManifest.Repository.FullName}
 {{if .Blob.Binary}}
 <p>Binary or large file; HTML preview skipped.</p>
 {{else}}
-<pre>{{.Blob.Text}}</pre>
+
+{{if .Blob.RenderedMarkdown}}
+<article class="markdown-body">
+{{.Blob.HTML}}
+</article>
+{{else}}
+<pre><code>{{.Blob.Text}}</code></pre>
+{{end}}
+
 {{end}}
 {{end}}
 `