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}