diff --git a/go.mod b/go.mod
index f608100..b72fbff 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed // indirect
github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf // indirect
github.com/spf13/pflag v1.0.9 // indirect
+ github.com/yuin/goldmark v1.7.16 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 8c787c0..0c38e50 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
+github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
diff --git a/internal/builder/builder.go b/internal/builder/builder.go
new file mode 100644
index 0000000..872072e
--- /dev/null
+++ b/internal/builder/builder.go
@@ -0,0 +1,51 @@
+package builder
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/renderer/html"
+)
+
+// Build converts a Markdown file to HTML using a template.
+// The template must contain a placeholder element that will be replaced with the rendered content.
+func Build(markdownPath, templatePath, outputPath string) error {
+ md, err := os.ReadFile(markdownPath)
+ if err != nil {
+ return fmt.Errorf("read markdown: %w", err)
+ }
+
+ tmpl, err := os.ReadFile(templatePath)
+ if err != nil {
+ return fmt.Errorf("read template: %w", err)
+ }
+
+ // Convert markdown to HTML
+ gm := goldmark.New(
+ goldmark.WithExtensions(extension.GFM),
+ goldmark.WithRendererOptions(html.WithUnsafe()),
+ )
+ var buf bytes.Buffer
+ if err := gm.Convert(md, &buf); err != nil {
+ return fmt.Errorf("convert markdown: %w", err)
+ }
+
+ // Inject into template — support both placeholder formats
+ output := string(tmpl)
+ replacements := []struct{ old, new string }{
+ {`
`, `` + buf.String() + `
`},
+ {``, ``},
+ }
+ for _, r := range replacements {
+ output = strings.Replace(output, r.old, r.new, 1)
+ }
+
+ if err := os.WriteFile(outputPath, []byte(output), 0644); err != nil {
+ return fmt.Errorf("write output: %w", err)
+ }
+ return nil
+}
diff --git a/internal/builder/builder_test.go b/internal/builder/builder_test.go
new file mode 100644
index 0000000..cdf992d
--- /dev/null
+++ b/internal/builder/builder_test.go
@@ -0,0 +1,113 @@
+package builder
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestBuild(t *testing.T) {
+ dir := t.TempDir()
+
+ md := "# Test List\n\n- [Example](https://example.com) - A test entry.\n"
+ mdPath := filepath.Join(dir, "README.md")
+ if err := os.WriteFile(mdPath, []byte(md), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ tmpl := `
+
+
+
+
+`
+ tmplPath := filepath.Join(dir, "template.html")
+ if err := os.WriteFile(tmplPath, []byte(tmpl), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ outPath := filepath.Join(dir, "index.html")
+ if err := Build(mdPath, tmplPath, outPath); err != nil {
+ t.Fatalf("Build failed: %v", err)
+ }
+
+ content, err := os.ReadFile(outPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ html := string(content)
+ if !strings.Contains(html, "Test List") {
+ t.Error("expected 'Test List' in output")
+ }
+ if !strings.Contains(html, "https://example.com") {
+ t.Error("expected link in output")
+ }
+}
+
+func TestBuildWithSectionPlaceholder(t *testing.T) {
+ dir := t.TempDir()
+
+ md := "# Hello\n\nWorld.\n"
+ mdPath := filepath.Join(dir, "README.md")
+ if err := os.WriteFile(mdPath, []byte(md), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // This matches the actual template format
+ tmpl := `
+
+
+
+
+`
+ tmplPath := filepath.Join(dir, "template.html")
+ if err := os.WriteFile(tmplPath, []byte(tmpl), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ outPath := filepath.Join(dir, "index.html")
+ if err := Build(mdPath, tmplPath, outPath); err != nil {
+ t.Fatalf("Build failed: %v", err)
+ }
+
+ content, err := os.ReadFile(outPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !strings.Contains(string(content), "Hello") {
+ t.Error("expected 'Hello' in output")
+ }
+ if !strings.Contains(string(content), `class="main-content"`) {
+ t.Error("expected section class preserved")
+ }
+}
+
+func TestBuildRealREADME(t *testing.T) {
+ mdPath := "../../README.md"
+ tmplPath := "../../config/website.tmpl.html"
+ if _, err := os.Stat(mdPath); err != nil {
+ t.Skip("README.md not found")
+ }
+ if _, err := os.Stat(tmplPath); err != nil {
+ t.Skip("website template not found")
+ }
+
+ dir := t.TempDir()
+ outPath := filepath.Join(dir, "index.html")
+
+ if err := Build(mdPath, tmplPath, outPath); err != nil {
+ t.Fatalf("Build failed: %v", err)
+ }
+
+ info, err := os.Stat(outPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Size() < 10000 {
+ t.Errorf("output too small: %d bytes", info.Size())
+ }
+ t.Logf("Generated %d bytes", info.Size())
+}