git-preview @ main

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

showcase/showcase.go

  1// Package showcase implements the sidecar `.showcase/` convention:
  2// repos may carry a `.showcase/showcase.yaml` manifest associating
  3// media assets (images, video, interactive HTML) with the repo landing
  4// page, individual files, or commits.
  5package showcase
  6
  7import (
  8	"fmt"
  9	"path"
 10	"strings"
 11
 12	"github.com/jxc2000-b/git-preview/git"
 13	"gopkg.in/yaml.v3"
 14)
 15
 16const Dir = ".showcase"
 17const manifestPath = Dir + "/showcase.yaml"
 18
 19type Asset struct {
 20	Asset   string `yaml:"asset"`
 21	Caption string `yaml:"caption"`
 22	Height  int    `yaml:"height"`
 23}
 24
 25// FileEntry is the per-file manifest value. It accepts either a plain
 26// asset list:
 27//
 28//	"src/foo.ts":
 29//	  - asset: demo.png
 30//
 31// or a mapping with an optional preview policy:
 32//
 33//	"src/foo.ts":
 34//	  preview: full | none | truncated
 35//	  assets:
 36//	    - asset: demo.png
 37type FileEntry struct {
 38	Assets  []Asset
 39	Preview string
 40}
 41
 42func (f *FileEntry) UnmarshalYAML(value *yaml.Node) error {
 43	if value.Kind == yaml.SequenceNode {
 44		return value.Decode(&f.Assets)
 45	}
 46	var aux struct {
 47		Assets  []Asset `yaml:"assets"`
 48		Preview string  `yaml:"preview"`
 49	}
 50	if err := value.Decode(&aux); err != nil {
 51		return err
 52	}
 53	f.Assets = aux.Assets
 54	f.Preview = aux.Preview
 55	return nil
 56}
 57
 58type Manifest struct {
 59	DefaultPreview string               `yaml:"defaultPreview"`
 60	Landing        []Asset              `yaml:"landing"`
 61	Files          map[string]FileEntry `yaml:"files"`
 62	Commits        map[string][]Asset   `yaml:"commits"`
 63}
 64
 65// Kind classifies an asset by extension: "image", "video", "html" or "".
 66func (a Asset) Kind() string {
 67	switch strings.ToLower(strings.TrimPrefix(path.Ext(a.Asset), ".")) {
 68	case "png", "jpg", "jpeg", "gif", "svg", "webp", "avif":
 69		return "image"
 70	case "mp4", "webm", "mov", "ogv":
 71		return "video"
 72	case "html", "htm":
 73		return "html"
 74	}
 75	return ""
 76}
 77
 78// IframeHeight returns the iframe height in px, defaulting to 360.
 79func (a Asset) IframeHeight() int {
 80	if a.Height > 0 {
 81		return a.Height
 82	}
 83	return 360
 84}
 85
 86// safe rejects asset paths that could escape the .showcase directory.
 87func safe(p string) bool {
 88	if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\\") {
 89		return false
 90	}
 91	clean := path.Clean(p)
 92	return clean == p && clean != ".." && !strings.HasPrefix(clean, "../")
 93}
 94
 95func filterSafe(as []Asset) []Asset {
 96	out := make([]Asset, 0, len(as))
 97	for _, a := range as {
 98		if safe(a.Asset) && a.Kind() != "" {
 99			out = append(out, a)
100		}
101	}
102	return out
103}
104
105// Load reads the showcase manifest from the repo at its opened ref.
106// A repo without a manifest yields (nil, nil).
107func Load(gr *git.GitRepo) (*Manifest, error) {
108	raw, err := gr.FileContentBytes(manifestPath)
109	if err != nil {
110		// Missing manifest just means the feature is unused.
111		return nil, nil
112	}
113
114	var m Manifest
115	if err := yaml.Unmarshal(raw, &m); err != nil {
116		return nil, fmt.Errorf("parsing %s: %w", manifestPath, err)
117	}
118
119	m.Landing = filterSafe(m.Landing)
120	for k, v := range m.Files {
121		v.Assets = filterSafe(v.Assets)
122		m.Files[k] = v
123	}
124	for k, v := range m.Commits {
125		m.Commits[k] = filterSafe(v)
126	}
127	return &m, nil
128}
129
130// ForFile returns assets associated with a repo-relative file path.
131func (m *Manifest) ForFile(p string) []Asset {
132	if m == nil {
133		return nil
134	}
135	return m.Files[path.Clean(p)].Assets
136}
137
138// PreviewMode returns the blob-view policy for a file: "full", "none",
139// or "truncated". A per-file policy takes precedence over defaultPreview;
140// when neither is set, the mode remains "truncated" for compatibility.
141func (m *Manifest) PreviewMode(p string) string {
142	if m != nil {
143		switch m.Files[path.Clean(p)].Preview {
144		case "full":
145			return "full"
146		case "none":
147			return "none"
148		case "truncated":
149			return "truncated"
150		}
151		switch m.DefaultPreview {
152		case "full":
153			return "full"
154		case "none":
155			return "none"
156		}
157	}
158	return "truncated"
159}
160
161// ForCommit returns assets associated with a commit hash; manifest keys
162// may be abbreviated (>= 7 chars) prefixes of the full hash.
163func (m *Manifest) ForCommit(hash string) []Asset {
164	if m == nil {
165		return nil
166	}
167	if as, ok := m.Commits[hash]; ok {
168		return as
169	}
170	for k, as := range m.Commits {
171		if len(k) >= 7 && strings.HasPrefix(hash, k) {
172			return as
173		}
174	}
175	return nil
176}