core.mnt provides zero-dep mount operations:
- mnt.FS(embed, "subdir") — scoped embed.FS access (debme pattern)
- mnt.Extract(fs, targetDir, data) — template directory extraction (gosod/Install pattern)
Template extraction supports:
- Go text/template in file contents (.tmpl suffix)
- Go text/template in directory and file names ({{.Name}})
- Ignore files, rename files
- Variable substitution from any struct or map
Based on leaanthony/debme (70 lines) + leaanthony/gosod (280 lines),
rewritten as single zero-dep package. All stdlib, no transitive deps.
8 tests covering FS, Sub, ReadFile, ReadString, ReadDir, Extract.
Co-Authored-By: Virgil <virgil@lethean.io>
194 lines
4.3 KiB
Go
194 lines
4.3 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package mnt
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/template"
|
|
)
|
|
|
|
// ExtractOptions configures template extraction.
|
|
type ExtractOptions struct {
|
|
// TemplateFilters identifies template files by substring match.
|
|
// Default: [".tmpl"]
|
|
TemplateFilters []string
|
|
|
|
// IgnoreFiles is a set of filenames to skip during extraction.
|
|
IgnoreFiles map[string]struct{}
|
|
|
|
// RenameFiles maps original filenames to new names.
|
|
RenameFiles map[string]string
|
|
}
|
|
|
|
// Extract copies a template directory from an fs.FS to targetDir,
|
|
// processing Go text/template in filenames and file contents.
|
|
//
|
|
// Files containing a template filter substring (default: ".tmpl") have
|
|
// their contents processed through text/template with the given data.
|
|
// The filter is stripped from the output filename.
|
|
//
|
|
// Directory and file names can contain Go template expressions:
|
|
// {{.Name}}/main.go → myproject/main.go
|
|
//
|
|
// Data can be any struct or map[string]string for template substitution.
|
|
func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) error {
|
|
opt := ExtractOptions{
|
|
TemplateFilters: []string{".tmpl"},
|
|
IgnoreFiles: make(map[string]struct{}),
|
|
RenameFiles: make(map[string]string),
|
|
}
|
|
if len(opts) > 0 {
|
|
if len(opts[0].TemplateFilters) > 0 {
|
|
opt.TemplateFilters = opts[0].TemplateFilters
|
|
}
|
|
if opts[0].IgnoreFiles != nil {
|
|
opt.IgnoreFiles = opts[0].IgnoreFiles
|
|
}
|
|
if opts[0].RenameFiles != nil {
|
|
opt.RenameFiles = opts[0].RenameFiles
|
|
}
|
|
}
|
|
|
|
// Ensure target directory exists
|
|
targetDir, err := filepath.Abs(targetDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Categorise files
|
|
var dirs []string
|
|
var templateFiles []string
|
|
var standardFiles []string
|
|
|
|
err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if path == "." {
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
dirs = append(dirs, path)
|
|
return nil
|
|
}
|
|
filename := filepath.Base(path)
|
|
if _, ignored := opt.IgnoreFiles[filename]; ignored {
|
|
return nil
|
|
}
|
|
if isTemplate(filename, opt.TemplateFilters) {
|
|
templateFiles = append(templateFiles, path)
|
|
} else {
|
|
standardFiles = append(standardFiles, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create directories (names may contain templates)
|
|
for _, dir := range dirs {
|
|
target := renderPath(filepath.Join(targetDir, dir), data)
|
|
if err := os.MkdirAll(target, 0755); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Process template files
|
|
for _, path := range templateFiles {
|
|
tmpl, err := template.ParseFS(fsys, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
targetFile := renderPath(filepath.Join(targetDir, path), data)
|
|
|
|
// Strip template filters from filename
|
|
dir := filepath.Dir(targetFile)
|
|
name := filepath.Base(targetFile)
|
|
for _, filter := range opt.TemplateFilters {
|
|
name = strings.ReplaceAll(name, filter, "")
|
|
}
|
|
if renamed := opt.RenameFiles[name]; renamed != "" {
|
|
name = renamed
|
|
}
|
|
targetFile = filepath.Join(dir, name)
|
|
|
|
f, err := os.Create(targetFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := tmpl.Execute(f, data); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
f.Close()
|
|
}
|
|
|
|
// Copy standard files
|
|
for _, path := range standardFiles {
|
|
name := filepath.Base(path)
|
|
if renamed := opt.RenameFiles[name]; renamed != "" {
|
|
path = filepath.Join(filepath.Dir(path), renamed)
|
|
}
|
|
target := renderPath(filepath.Join(targetDir, path), data)
|
|
if err := copyFile(fsys, path, target); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func isTemplate(filename string, filters []string) bool {
|
|
for _, f := range filters {
|
|
if strings.Contains(filename, f) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func renderPath(path string, data any) string {
|
|
if data == nil {
|
|
return path
|
|
}
|
|
tmpl, err := template.New("path").Parse(path)
|
|
if err != nil {
|
|
return path
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.Execute(&buf, data); err != nil {
|
|
return path
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
func copyFile(fsys fs.FS, source, target string) error {
|
|
s, err := fsys.Open(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
d, err := os.Create(target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer d.Close()
|
|
|
|
_, err = io.Copy(d, s)
|
|
return err
|
|
}
|