git-preview @ main

static git web frontend but with images and html previews + code truncation

export/export.go

  1// Package export renders the whole site to static files by crawling
  2// the HTTP mux in-process. The output is suitable for any static host
  3// (e.g. Cloudflare Pages with output directory pointing here).
  4package export
  5
  6import (
  7	"fmt"
  8	"log"
  9	"net/http/httptest"
 10	"os"
 11	"path"
 12	"path/filepath"
 13
 14	"github.com/jxc2000-b/git-preview/config"
 15	"github.com/jxc2000-b/git-preview/git"
 16	"github.com/jxc2000-b/git-preview/routes"
 17	"github.com/jxc2000-b/git-preview/showcase"
 18	securejoin "github.com/cyphar/filepath-securejoin"
 19)
 20
 21// Run exports the site for config c into outDir.
 22func Run(c *config.Config, outDir string) error {
 23	mux := routes.Handlers(c)
 24
 25	fetch := func(urlPath string) ([]byte, int, string) {
 26		req := httptest.NewRequest("GET", urlPath, nil)
 27		rec := httptest.NewRecorder()
 28		mux.ServeHTTP(rec, req)
 29		return rec.Body.Bytes(), rec.Code, rec.Header().Get("Content-Type")
 30	}
 31
 32	// writePage stores an HTML page as <urlPath>/index.html so static
 33	// hosts serve it at the same URL the live server used.
 34	writePage := func(urlPath string) error {
 35		body, code, _ := fetch(urlPath)
 36		if code != 200 {
 37			return fmt.Errorf("GET %s: status %d", urlPath, code)
 38		}
 39		rel := path.Clean("/" + urlPath)[1:]
 40		fp := filepath.Join(outDir, filepath.FromSlash(rel), "index.html")
 41		if rel == "" {
 42			fp = filepath.Join(outDir, "index.html")
 43		}
 44		if err := os.MkdirAll(filepath.Dir(fp), 0755); err != nil {
 45			return err
 46		}
 47		return os.WriteFile(fp, body, 0644)
 48	}
 49
 50	// writeAsset stores a response at its literal path (real filename,
 51	// real extension) — used for showcase media and static files.
 52	writeAsset := func(urlPath string) error {
 53		body, code, _ := fetch(urlPath)
 54		if code != 200 {
 55			return fmt.Errorf("GET %s: status %d", urlPath, code)
 56		}
 57		fp := filepath.Join(outDir, filepath.FromSlash(path.Clean("/"+urlPath)[1:]))
 58		if err := os.MkdirAll(filepath.Dir(fp), 0755); err != nil {
 59			return err
 60		}
 61		return os.WriteFile(fp, body, 0644)
 62	}
 63
 64	if err := os.MkdirAll(outDir, 0755); err != nil {
 65		return err
 66	}
 67
 68	if err := writePage("/"); err != nil {
 69		return err
 70	}
 71
 72	// Static assets, straight from the static dir.
 73	statics, err := os.ReadDir(c.Dirs.Static)
 74	if err != nil {
 75		return fmt.Errorf("static dir: %w", err)
 76	}
 77	for _, s := range statics {
 78		if s.IsDir() {
 79			continue
 80		}
 81		if err := writeAsset("/static/" + s.Name()); err != nil {
 82			return err
 83		}
 84	}
 85
 86	// Repos.
 87	dirs, err := os.ReadDir(c.Repo.ScanPath)
 88	if err != nil {
 89		return fmt.Errorf("scan path: %w", err)
 90	}
 91
 92	var headers []string
 93
 94	for _, dir := range dirs {
 95		name := dir.Name()
 96		if !dir.IsDir() || ignored(c, name) {
 97			continue
 98		}
 99
100		repoPath, err := securejoin.SecureJoin(c.Repo.ScanPath, name)
101		if err != nil {
102			return err
103		}
104
105		gr, err := git.Open(repoPath, "")
106		if err != nil {
107			log.Printf("skipping %s: %s", name, err)
108			continue
109		}
110
111		ref, err := gr.FindMainBranch(c.Repo.MainBranch)
112		if err != nil {
113			log.Printf("skipping %s: %s", name, err)
114			continue
115		}
116		// Re-open at the named ref so blob/tree URLs match live ones.
117		gr, err = git.Open(repoPath, ref)
118		if err != nil {
119			return err
120		}
121
122		base := "/" + name
123		if err := writePage(base); err != nil {
124			return err
125		}
126		if err := writePage(base + "/log/" + ref); err != nil {
127			return err
128		}
129
130		// Tree and blob pages.
131		var walk func(prefix string) error
132		walk = func(prefix string) error {
133			entries, err := gr.FileTree(prefix)
134			if err != nil {
135				return err
136			}
137			if err := writePage(base + "/tree/" + ref + "/" + prefix); err != nil {
138				return err
139			}
140			for _, e := range entries {
141				p := path.Join(prefix, e.Name)
142				if prefix == "" && e.Name == showcase.Dir {
143					continue
144				}
145				if e.IsFile {
146					if err := writePage(base + "/blob/" + ref + "/" + p); err != nil {
147						return err
148					}
149				} else {
150					if err := walk(p); err != nil {
151						return err
152					}
153				}
154			}
155			return nil
156		}
157		if err := walk(""); err != nil {
158			return err
159		}
160
161		// Commit pages.
162		commits, err := gr.Commits()
163		if err != nil {
164			return err
165		}
166		for _, cm := range commits {
167			if err := writePage(base + "/commit/" + cm.Hash.String()); err != nil {
168				return err
169			}
170		}
171
172		// Showcase assets referenced by the manifest.
173		sc, err := showcase.Load(gr)
174		if err != nil {
175			log.Printf("showcase manifest for %s: %s", name, err)
176		}
177		if sc != nil {
178			seen := map[string]bool{}
179			var assets []showcase.Asset
180			assets = append(assets, sc.Landing...)
181			for _, fe := range sc.Files {
182				assets = append(assets, fe.Assets...)
183			}
184			for _, as := range sc.Commits {
185				assets = append(assets, as...)
186			}
187			for _, a := range assets {
188				if seen[a.Asset] {
189					continue
190				}
191				seen[a.Asset] = true
192				if err := writeAsset(base + "/showcase/" + ref + "/" + a.Asset); err != nil {
193					return err
194				}
195			}
196			// Sandbox interactive widgets on the static host too.
197			headers = append(headers,
198				fmt.Sprintf("%s/showcase/%s/*", base, ref),
199				"  Content-Security-Policy: sandbox allow-scripts",
200				"  X-Content-Type-Options: nosniff",
201				"")
202		}
203	}
204
205	if len(headers) > 0 {
206		out := ""
207		for _, l := range headers {
208			out += l + "\n"
209		}
210		if err := os.WriteFile(filepath.Join(outDir, "_headers"), []byte(out), 0644); err != nil {
211			return err
212		}
213	}
214
215	return nil
216}
217
218func ignored(c *config.Config, name string) bool {
219	for _, i := range c.Repo.Ignore {
220		if name == i {
221			return true
222		}
223	}
224	return false
225}