diff --git a/cli/cmd/public.go b/cli/cmd/public.go new file mode 100644 index 0000000..bf82f2d --- /dev/null +++ b/cli/cmd/public.go @@ -0,0 +1,53 @@ +/* +Copyright © 2022 NAME HERE +*/ +package cmd + +import ( + "fmt" + "os" + + "github.com/adnaan/fir" + "github.com/spf13/cobra" +) + +var ( + inDir string + outDir string + extensions []string +) + +// publicCmd represents the public command +var publicCmd = &cobra.Command{ + Use: "public", + Short: "Generates the public folder containing the html files", + Long: `The public command generates a public folder containing the html files in the project. + It preserves the paths of the html files enabling a flexible project structure. The generated public directory + can be embedded in the binary as is.`, + Run: func(cmd *cobra.Command, args []string) { + var opts []fir.PublicOption + if inDir != "" { + opts = append(opts, fir.InDir(inDir)) + } + + if outDir != "" { + opts = append(opts, fir.OutDir(outDir)) + } + + if len(extensions) != 0 { + opts = append(opts, fir.Extensions(extensions)) + } + + if err := fir.GeneratePublic(opts...); err != nil { + fmt.Println(err) + os.Exit(1) + } + }, +} + +func init() { + rootCmd.AddCommand(publicCmd) + publicCmd.Flags().StringVarP(&inDir, "in", "i", "", "path to input directory which contains the html template files") + publicCmd.Flags().StringVarP(&outDir, "out", "o", "", "path to output directory") + publicCmd.Flags().StringSliceVarP(&extensions, "extensions", "x", nil, "comma separated list of template exatensions e.g. .html,.tmpl") +} diff --git a/controller.go b/controller.go index 6a2d4c7..58cf7c5 100644 --- a/controller.go +++ b/controller.go @@ -1,6 +1,7 @@ package fir import ( + "embed" "flag" "log" "net/http" @@ -30,6 +31,8 @@ type controlOpt struct { developmentMode bool errorView View cookieStore *sessions.CookieStore + embedFS embed.FS + hasEmbedFS bool } type Option func(*controlOpt) @@ -58,6 +61,13 @@ func WithCookieStore(cookieStore *sessions.CookieStore) Option { } } +func WithEmbedFS(fs embed.FS) Option { + return func(o *controlOpt) { + o.embedFS = fs + o.hasEmbedFS = true + } +} + func DisableTemplateCache() Option { return func(o *controlOpt) { o.disableTemplateCache = true @@ -143,6 +153,12 @@ func NewController(name string, options ...Option) Controller { if wc.enableWatch { go watchTemplates(wc) } + + if wc.hasEmbedFS { + log.Println("read template files embedded in the binary") + } else { + log.Println("read template files from disk") + } return wc } @@ -302,12 +318,12 @@ func (wc *websocketController) getUser(w http.ResponseWriter, r *http.Request) ( } func (wc *websocketController) Handler(view View) http.HandlerFunc { - viewTemplate, err := parseTemplate(wc.publicDir, view) + viewTemplate, err := parseTemplate(wc.controlOpt, view) if err != nil { panic(err) } - errorViewTemplate, err := parseTemplate(wc.publicDir, wc.errorView) + errorViewTemplate, err := parseTemplate(wc.controlOpt, wc.errorView) if err != nil { panic(err) } diff --git a/public.go b/public.go index 0b859fa..83ea702 100644 --- a/public.go +++ b/public.go @@ -2,6 +2,7 @@ package fir import ( "io/fs" + "log" "os" "path/filepath" @@ -9,16 +10,16 @@ import ( ) type publicOpt struct { - inputDir string + inDir string outDir string extensions []string } type PublicOption func(*publicOpt) -func InputDir(path string) PublicOption { +func InDir(path string) PublicOption { return func(o *publicOpt) { - o.inputDir = path + o.inDir = path } } @@ -36,7 +37,7 @@ func Extensions(extensions []string) PublicOption { func GeneratePublic(options ...PublicOption) error { opt := &publicOpt{ - inputDir: ".", + inDir: ".", outDir: "./public", extensions: []string{".html"}, } @@ -49,30 +50,30 @@ func GeneratePublic(options ...PublicOption) error { return err } - ignore, err := gitignore.CompileIgnoreFile(".gitignore") + ignore, err := gitignore.CompileIgnoreFile(filepath.Join(opt.inDir, ".gitignore")) if err != nil { - return err + log.Printf("[warning] failed to compile .gitignore: %v\n", err) } - err = filepath.WalkDir(opt.inputDir, func(path string, d fs.DirEntry, err error) error { + err = filepath.WalkDir(opt.inDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { - if d.Name() == filepath.Clean(opt.outDir) { + if d.Name() == filepath.Base(opt.outDir) { return filepath.SkipDir } if d.Name() == ".git" { return filepath.SkipDir } - if ignore.MatchesPath(d.Name()) { + if ignore != nil && ignore.MatchesPath(d.Name()) { return filepath.SkipDir } return nil } - if ignore.MatchesPath(path) { + if ignore != nil && ignore.MatchesPath(path) { return nil } @@ -80,7 +81,12 @@ func GeneratePublic(options ...PublicOption) error { return nil } - outPath := filepath.Join(opt.outDir, path) + relpath, err := filepath.Rel(opt.inDir, path) + if err != nil { + return err + } + + outPath := filepath.Join(opt.outDir, relpath) if err := os.MkdirAll(filepath.Dir(outPath), os.ModePerm); err != nil { return err } diff --git a/view.go b/view.go index cc0cf64..7c9ac46 100644 --- a/view.go +++ b/view.go @@ -214,12 +214,12 @@ func (v *viewHandler) reloadTemplates() { var err error if v.wc.disableTemplateCache { - v.viewTemplate, err = parseTemplate(v.wc.publicDir, v.view) + v.viewTemplate, err = parseTemplate(v.wc.controlOpt, v.view) if err != nil { panic(err) } - v.errorViewTemplate, err = parseTemplate(v.wc.publicDir, v.errorView) + v.errorViewTemplate, err = parseTemplate(v.wc.controlOpt, v.errorView) if err != nil { panic(err) } @@ -540,118 +540,112 @@ loop: } } -// creates a html/template from the View type. -func parseTemplate(publicDir string, view View) (*template.Template, error) { - // if both layout and content is empty show a default view. - if view.Layout() == "" && view.Content() == "" { - return template.Must(template.New(""). - Parse(`
This is a default view.
`)), nil +func layoutSetContentEmpty(opt controlOpt, view View) (*template.Template, error) { + viewLayoutPath := filepath.Join(opt.publicDir, view.Layout()) + // is layout html content or a file/directory + if isFileHTML(viewLayoutPath, opt) { + return template.Must(template.New("").Funcs(view.FuncMap()).Parse(view.Layout())), nil } - // if layout is set and content is empty - if view.Layout() != "" && view.Content() == "" { - var layoutTemplate *template.Template - // check if layout is not a file or directory - if _, err := os.Stat(filepath.Join(publicDir, view.Layout())); err != nil { - // is not a file but html content - layoutTemplate = template.Must(template.New("").Funcs(view.FuncMap()).Parse(view.Layout())) - } else { - // layout must be a file - viewLayoutPath := filepath.Join(publicDir, view.Layout()) - ok, err := isDirectory(viewLayoutPath) - if err == nil && ok { - return nil, fmt.Errorf("layout is a directory but it must be a file") - } + // layout must be a file or directory + if !isDir(viewLayoutPath, opt) { + return nil, fmt.Errorf("layout %s is not a file or directory", viewLayoutPath) + } + // compile layout + commonFiles := []string{viewLayoutPath} + // global partials + for _, p := range view.Partials() { + commonFiles = append(commonFiles, find(opt, filepath.Join(opt.publicDir, p), view.Extensions())...) + } - if err != nil { - return nil, err - } - // compile layout - commonFiles := []string{viewLayoutPath} - // global partials - for _, p := range view.Partials() { - commonFiles = append(commonFiles, find(filepath.Join(publicDir, p), view.Extensions())...) - } - layoutTemplate = template.Must(template.New(viewLayoutPath). - Funcs(view.FuncMap()). - ParseFiles(commonFiles...)) - } - return template.Must(layoutTemplate.Clone()), nil + layoutTemplate := template.New(viewLayoutPath).Funcs(view.FuncMap()) + if opt.hasEmbedFS { + layoutTemplate = template.Must(layoutTemplate.ParseFS(opt.embedFS, commonFiles...)) + } else { + layoutTemplate = template.Must(layoutTemplate.ParseFiles(commonFiles...)) } - // if layout is empty and content is set - if view.Layout() == "" && view.Content() != "" { - // check if content is a not a file or directory - if _, err := os.Stat(filepath.Join(publicDir, view.Content())); err != nil { - return template.Must( - template.New( - view.LayoutContentName()). - Funcs(view.FuncMap()). - Parse(view.Content()), - ), nil - } else { - // is a file or directory - viewContentPath := filepath.Join(publicDir, view.Content()) - var pageFiles []string - // view and its partials - pageFiles = append(pageFiles, find(viewContentPath, view.Extensions())...) - for _, p := range view.Partials() { - pageFiles = append(pageFiles, find(filepath.Join(publicDir, p), view.Extensions())...) - } - return template.Must(template.New(filepath.Base(viewContentPath)). + return template.Must(layoutTemplate.Clone()), nil +} + +func layoutEmptyContentSet(opt controlOpt, view View) (*template.Template, error) { + // is content html content or a file/directory + viewContentPath := filepath.Join(opt.publicDir, view.Content()) + if isFileHTML(viewContentPath, opt) { + return template.Must( + template.New( + view.LayoutContentName()). Funcs(view.FuncMap()). - ParseFiles(pageFiles...)), nil - } + Parse(view.Content()), + ), nil } + // content must be a file or directory - // if both layout and content are set - var viewTemplate *template.Template - // 1. build layout + var pageFiles []string + // view and its partials + pageFiles = append(pageFiles, find(opt, viewContentPath, view.Extensions())...) + for _, p := range view.Partials() { + pageFiles = append(pageFiles, find(opt, filepath.Join(opt.publicDir, p), view.Extensions())...) + } + + contentTemplate := template.New(viewContentPath).Funcs(view.FuncMap()) + if opt.hasEmbedFS { + contentTemplate = template.Must(contentTemplate.ParseFS(opt.embedFS, pageFiles...)) + } else { + contentTemplate = template.Must(contentTemplate.ParseFiles(pageFiles...)) + } + + return contentTemplate, nil +} + +func layoutSetContentSet(opt controlOpt, view View) (*template.Template, error) { + // 1. build layout template + viewLayoutPath := filepath.Join(opt.publicDir, view.Layout()) var layoutTemplate *template.Template - // check if layout is not a file or directory - if _, err := os.Stat(filepath.Join(publicDir, view.Layout())); err != nil { - // is not a file but html content + // is layout, html content or a file/directory + if isFileHTML(viewLayoutPath, opt) { layoutTemplate = template.Must(template.New("base").Funcs(view.FuncMap()).Parse(view.Layout())) } else { - // layout must be a file - viewLayoutPath := filepath.Join(publicDir, view.Layout()) - ok, err := isDirectory(viewLayoutPath) - if err == nil && ok { - return nil, fmt.Errorf("layout is a directory but it must be a file") + // layout must be a file or directory + if isDir(viewLayoutPath, opt) { + return nil, fmt.Errorf("layout %s is a directory but must be a file", viewLayoutPath) } - if err != nil { - return nil, err - } // compile layout commonFiles := []string{viewLayoutPath} // global partials for _, p := range view.Partials() { - commonFiles = append(commonFiles, find(filepath.Join(publicDir, p), view.Extensions())...) + commonFiles = append(commonFiles, find(opt, filepath.Join(opt.publicDir, p), view.Extensions())...) } - layoutTemplate = template.Must( - template.New(filepath.Base(viewLayoutPath)). - Funcs(view.FuncMap()). - ParseFiles(commonFiles...)) - //log.Println("compiled layoutTemplate...") - //for _, v := range layoutTemplate.Templates() { - // fmt.Println("template => ", v.Name()) - //} + layoutTemplate = template.New(filepath.Base(viewLayoutPath)).Funcs(view.FuncMap()) + if opt.hasEmbedFS { + layoutTemplate = template.Must(layoutTemplate.ParseFS(opt.embedFS, commonFiles...)) + } else { + layoutTemplate = template.Must(layoutTemplate.ParseFiles(commonFiles...)) + } } - // 2. add content + //log.Println("compiled layoutTemplate...") + //for _, v := range layoutTemplate.Templates() { + // fmt.Println("template => ", v.Name()) + //} + + // 2. add content to layout // check if content is a not a file or directory - if _, err := os.Stat(filepath.Join(publicDir, view.Content())); err != nil { - // content is not a file or directory but html content + var viewTemplate *template.Template + viewContentPath := filepath.Join(opt.publicDir, view.Content()) + if isFileHTML(viewContentPath, opt) { viewTemplate = template.Must(layoutTemplate.Parse(view.Content())) } else { - // content is a file or directory var pageFiles []string // view and its partials - pageFiles = append(pageFiles, find(filepath.Join(publicDir, view.Content()), view.Extensions())...) - - viewTemplate = template.Must(layoutTemplate.ParseFiles(pageFiles...)) + pageFiles = append(pageFiles, find(opt, filepath.Join(opt.publicDir, view.Content()), view.Extensions())...) + if opt.hasEmbedFS { + viewTemplate = template.Must(layoutTemplate.ParseFS(opt.embedFS, pageFiles...)) + } else { + viewTemplate = template.Must(layoutTemplate.ParseFiles(pageFiles...)) + } } // check if the final viewTemplate contains a content child template which is `content` by default. @@ -664,6 +658,28 @@ func parseTemplate(publicDir string, view View) (*template.Template, error) { return viewTemplate, nil } +// creates a html/template from the View type. +func parseTemplate(opt controlOpt, view View) (*template.Template, error) { + // if both layout and content is empty show a default view. + if view.Layout() == "" && view.Content() == "" { + return template.Must(template.New(""). + Parse(`
This is a default view.
`)), nil + } + + // if layout is set and content is empty + if view.Layout() != "" && view.Content() == "" { + return layoutSetContentEmpty(opt, view) + } + + // if layout is empty and content is set + if view.Layout() == "" && view.Content() != "" { + return layoutEmptyContentSet(opt, view) + } + + // both layout and content are set + return layoutSetContentSet(opt, view) +} + var DefaultUserErrorMessage = "internal error" func UserError(err error) string { @@ -674,13 +690,23 @@ func UserError(err error) string { return userMessage } -func find(p string, extensions []string) []string { +func find(opt controlOpt, p string, extensions []string) []string { var files []string + var fi fs.FileInfo + var err error - fi, err := os.Stat(p) - if err != nil { - return files + if opt.hasEmbedFS { + fi, err = fs.Stat(opt.embedFS, p) + if err != nil { + return files + } + } else { + fi, err = os.Stat(p) + if err != nil { + return files + } } + if !fi.IsDir() { if !contains(extensions, filepath.Ext(p)) { return files @@ -688,19 +714,40 @@ func find(p string, extensions []string) []string { files = append(files, p) return files } - err = filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error { + + if opt.hasEmbedFS { + err = fs.WalkDir(opt.embedFS, p, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if contains(extensions, filepath.Ext(d.Name())) { + files = append(files, path) + } + return nil + }) + if err != nil { - return err + panic(err) } - if contains(extensions, filepath.Ext(d.Name())) { - files = append(files, path) + } else { + + err = filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if contains(extensions, filepath.Ext(d.Name())) { + files = append(files, path) + } + return nil + }) + + if err != nil { + panic(err) } - return nil - }) - if err != nil { - panic(err) } return files @@ -723,3 +770,34 @@ func isDirectory(path string) (bool, error) { return fileInfo.IsDir(), err } + +func isDir(path string, opt controlOpt) bool { + if opt.hasEmbedFS { + fileInfo, err := fs.Stat(opt.embedFS, path) + if err != nil { + fmt.Println("[warning]isDir warn: ", err) + return false + } + return fileInfo.IsDir() + } + fileInfo, err := os.Stat(path) + if err != nil { + fmt.Println("[warning]isDir error: ", err) + return false + } + + return fileInfo.IsDir() +} + +func isFileHTML(path string, opt controlOpt) bool { + if opt.hasEmbedFS { + if _, err := fs.Stat(opt.embedFS, path); err != nil { + return true + } + return false + } + if _, err := os.Stat(path); err != nil { + return true + } + return false +}