Repository navigation
Expand file tree
/
Copy pathsource_loader.go
More file actions
257 lines (220 loc) · 8.45 KB
/
Copy pathsource_loader.go
File metadata and controls
257 lines (220 loc) · 8.45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
package templar
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// SourceConfig represents a single external template source configuration
type SourceConfig struct {
URL string `yaml:"url"` // Repository URL (e.g., github.com/user/repo)
Path string `yaml:"path"` // Directory within repo to fetch (e.g., templates)
Version string `yaml:"version,omitempty"` // Semantic version tag (e.g., v1.2.0)
Ref string `yaml:"ref,omitempty"` // Git ref - branch or commit (fallback if no version)
Include []string `yaml:"include,omitempty"` // Glob patterns to include (e.g., ["**/*.html"])
Exclude []string `yaml:"exclude,omitempty"` // Glob patterns to exclude (e.g., ["*_test.*"])
}
// GetRef returns the effective git ref (version takes precedence over ref)
func (s *SourceConfig) GetRef() string {
if s.Version != "" {
return s.Version
}
if s.Ref != "" {
return s.Ref
}
return "main" // Default to main branch
}
// VendorConfig represents the templar.yaml configuration
type VendorConfig struct {
Sources map[string]SourceConfig `yaml:"sources"`
VendorDir string `yaml:"vendor_dir"`
SearchPaths []string `yaml:"search_paths"`
RequireLock bool `yaml:"require_lock"`
// FS is the filesystem for template resolution. Required.
// SearchPaths and VendorDir are paths within this FS.
// Use NewLocalFS(root) for local disk, NewMemFS() for tests.
FS WritableFS `yaml:"-"`
// configDir is the directory containing the config file (for resolving relative paths)
configDir string
}
// LoadVendorConfig loads a VendorConfig from a config file, applying templar's
// standard defaults. For custom defaults, use LoadVendorConfigWithDefaults.
func LoadVendorConfig(path string) (*VendorConfig, error) {
return LoadVendorConfigWithDefaults(path, DefaultToolInfo())
}
// LoadVendorConfigWithDefaults loads a VendorConfig from a config file, applying
// defaults from the given ToolInfo. Embedding applications use this to set their
// own default vendor directory when the config file doesn't specify one.
func LoadVendorConfigWithDefaults(path string, info ToolInfo) (*VendorConfig, error) {
data, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var config VendorConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
// Store the config directory for resolving relative paths. Absolutize
// so ResolveVendorDir/ResolveSearchPaths always return absolute paths,
// which compose correctly with NewLocalFS("/") regardless of how the
// caller phrased path.
absPath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("failed to resolve absolute config path: %w", err)
}
config.configDir = filepath.Dir(absPath)
// Apply defaults from ToolInfo
if config.VendorDir == "" {
config.VendorDir = info.VendorDir
}
if len(config.SearchPaths) == 0 {
config.SearchPaths = []string{"./templates", config.VendorDir}
}
return &config, nil
}
// FindVendorConfig searches for templar.yaml starting from the given directory
// and walking up to parent directories until found or root is reached.
// For custom config file names, use FindVendorConfigWithNames.
func FindVendorConfig(startDir string) (string, error) {
return FindVendorConfigWithNames(startDir, DefaultConfigNames)
}
// FindVendorConfigWithNames searches for a config file with any of the given names,
// starting from startDir and walking up to parent directories. Embedding applications
// use this to search for their own config file names (e.g. ".slyds.yaml").
func FindVendorConfigWithNames(startDir string, configNames []string) (string, error) {
dir, err := filepath.Abs(startDir)
if err != nil {
return "", err
}
for {
for _, name := range configNames {
configPath := filepath.Join(dir, name)
if _, err := os.Stat(configPath); err == nil {
return configPath, nil
}
}
// Move to parent directory
parent := filepath.Dir(dir)
if parent == dir {
// Reached root
return "", fmt.Errorf("config file not found in %s or any parent directory (searched for: %s)",
startDir, strings.Join(configNames, ", "))
}
dir = parent
}
}
// ResolveVendorDir returns the absolute path to the vendor directory
func (c *VendorConfig) ResolveVendorDir() string {
if filepath.IsAbs(c.VendorDir) {
return c.VendorDir
}
return filepath.Join(c.configDir, c.VendorDir)
}
// ResolveSearchPaths returns absolute paths for all search paths
func (c *VendorConfig) ResolveSearchPaths() []string {
resolved := make([]string, len(c.SearchPaths))
for i, p := range c.SearchPaths {
if filepath.IsAbs(p) {
resolved[i] = p
} else {
resolved[i] = filepath.Join(c.configDir, p)
}
}
return resolved
}
// NewSourceLoaderFromConfig creates a SourceLoader from a config file path.
// It loads the config, resolves all paths relative to the config file location,
// and creates the appropriate loader.
func NewSourceLoaderFromConfig(configPath string) (*SourceLoader, error) {
config, err := LoadVendorConfig(configPath)
if err != nil {
return nil, err
}
// Resolve paths relative to config file
config.VendorDir = config.ResolveVendorDir()
config.SearchPaths = config.ResolveSearchPaths()
return NewSourceLoader(config), nil
}
// NewSourceLoaderFromDir finds templar.yaml starting from the given directory
// and creates a SourceLoader from it.
func NewSourceLoaderFromDir(dir string) (*SourceLoader, error) {
configPath, err := FindVendorConfig(dir)
if err != nil {
return nil, err
}
return NewSourceLoaderFromConfig(configPath)
}
// SourceLoader is a template loader that resolves @source prefixed paths
// to vendored template locations, while falling back to a FileSystemLoader
// for regular paths.
type SourceLoader struct {
config *VendorConfig
fsLoader *FileSystemLoader
extensions []string
}
// NewSourceLoader creates a new SourceLoader with the given configuration.
// If config.FS is nil, it defaults to a LocalFS rooted at "/" so absolute
// search paths and vendor dirs resolve consistently and loaded template
// Paths are absolute (avoiding collisions between local and vendored
// templates that share a base name).
func NewSourceLoader(config *VendorConfig) *SourceLoader {
if config.FS == nil {
config.FS = NewLocalFS("/")
}
// Build file system loader from search paths, backed by config.FS
var folders []FSFolder
for _, p := range config.SearchPaths {
folders = append(folders, FSFolder{FS: config.FS, Path: p})
}
fsLoader := &FileSystemLoader{
Folders: folders,
Extensions: []string{"tmpl", "tmplus", "html"},
}
return &SourceLoader{
config: config,
fsLoader: fsLoader,
extensions: []string{"tmpl", "tmplus", "html"},
}
}
// Load attempts to load templates matching the given pattern.
// If the pattern starts with @sourcename/, it resolves to the vendored location.
// Otherwise, it delegates to the underlying FileSystemLoader.
func (s *SourceLoader) Load(pattern string, cwd string) ([]*Template, error) {
// Check if pattern starts with @
if strings.HasPrefix(pattern, "@") {
return s.loadFromSource(pattern, cwd)
}
// Fall back to file system loader
return s.fsLoader.Load(pattern, cwd)
}
// loadFromSource resolves @sourcename/path to the vendored location
func (s *SourceLoader) loadFromSource(pattern string, cwd string) ([]*Template, error) {
// Pattern is @sourcename/path/to/file.html
// Extract source name and path
withoutAt := pattern[1:] // Remove @
slashIdx := strings.Index(withoutAt, "/")
if slashIdx == -1 {
return nil, fmt.Errorf("invalid source pattern '%s': expected @sourcename/path", pattern)
}
sourceName := withoutAt[:slashIdx]
sourcePath := withoutAt[slashIdx+1:]
// Look up source in config
_, ok := s.config.Sources[sourceName]
if !ok {
return nil, fmt.Errorf("source '%s' not defined in config (pattern: %s)", sourceName, pattern)
}
// Build the vendored path: VendorDir/sourceName/sourcePath
vendoredDir := s.config.VendorDir + "/" + sourceName
vendoredBase := sourcePath
// Extract directory part if sourcePath has subdirectories
if lastSlash := strings.LastIndex(sourcePath, "/"); lastSlash >= 0 {
vendoredDir = vendoredDir + "/" + sourcePath[:lastSlash]
vendoredBase = sourcePath[lastSlash+1:]
}
vendorLoader := &FileSystemLoader{
Folders: []FSFolder{{FS: s.config.FS, Path: vendoredDir}},
Extensions: s.extensions,
}
return vendorLoader.Load(vendoredBase, "")
}