package site import ( "bytes" "fmt" "io" "io/fs" "os" "path/filepath" "runtime" "slices" "strings" "sync" "time" "github.com/facundoolano/jorge/config" "github.com/facundoolano/jorge/markup" "gopkg.in/yaml.v3" ) const FILE_RW_MODE = 0666 const DIR_RWE_MODE = 0777 type Site struct { Config config.Config layouts map[string]markup.Template posts []map[string]interface{} pages []map[string]interface{} tags map[string][]map[string]interface{} data map[string]interface{} templateEngine *markup.Engine templates map[string]*markup.Template minifier markup.Minifier } func Load(config config.Config) (*Site, error) { site := Site{ layouts: make(map[string]markup.Template), templates: make(map[string]*markup.Template), Config: config, tags: make(map[string][]map[string]interface{}), data: make(map[string]interface{}), templateEngine: markup.NewEngine(config.SiteUrl, config.IncludesDir), } if err := site.loadDataFiles(); err != nil { return nil, err } if err := site.loadLayouts(); err != nil { return nil, err } if err := site.loadTemplates(); err != nil { return nil, err } site.minifier = markup.LoadMinifier() return &site, nil } func (site *Site) loadLayouts() error { files, err := os.ReadDir(site.Config.LayoutsDir) if os.IsNotExist(err) { return nil } else if err != nil { return err } for _, entry := range files { if !entry.IsDir() { filename := entry.Name() path := filepath.Join(site.Config.LayoutsDir, filename) templ, err := markup.Parse(site.templateEngine, path) if err != nil { return checkFileError(err) } layout_name := strings.TrimSuffix(filename, filepath.Ext(filename)) site.layouts[layout_name] = *templ } } return nil } func (site *Site) loadDataFiles() error { files, err := os.ReadDir(site.Config.DataDir) if os.IsNotExist(err) { return nil } else if err != nil { return err } for _, entry := range files { if !entry.IsDir() { filename := entry.Name() path := filepath.Join(site.Config.DataDir, filename) yamlContent, err := os.ReadFile(path) if err != nil { return err } var data interface{} err = yaml.Unmarshal(yamlContent, &data) if err != nil { return err } data_name := strings.TrimSuffix(filename, filepath.Ext(filename)) site.data[data_name] = data } } return nil } func (site *Site) loadTemplates() error { if _, err := os.Stat(site.Config.SrcDir); err != nil { return fmt.Errorf("missing src directory") } err := filepath.WalkDir(site.Config.SrcDir, func(path string, entry fs.DirEntry, err error) error { if !entry.IsDir() { templ, err := markup.Parse(site.templateEngine, path) // if something fails or this is not a template, skip if err != nil || templ == nil { return checkFileError(err) } // set site related (?) metadata. Not sure if this should go elsewhere relPath, _ := filepath.Rel(site.Config.SrcDir, path) srcPath, _ := filepath.Rel(site.Config.RootDir, path) relPath = strings.TrimSuffix(relPath, filepath.Ext(relPath)) + templ.TargetExt() templ.Metadata["src_path"] = srcPath templ.Metadata["path"] = relPath templ.Metadata["url"] = "/" + strings.TrimSuffix(strings.TrimSuffix(relPath, "index.html"), ".html") templ.Metadata["dir"] = "/" + filepath.Dir(relPath) // if drafts are disabled, exclude from posts, page and tags indexes, but not from site.templates // we want to explicitly exclude the template from the target, rather than treating it as a non template file if !templ.IsDraft() || site.Config.IncludeDrafts { // posts are templates that can be chronologically sorted --that have a date. // the rest are pages. if templ.IsPost() { // NOTE: getting the excerpt if not set at the front matter requires rendering the template // which could be too onerous for this stage. Consider postponing setting this and/or caching the // template render result templ.Metadata["excerpt"] = getExcerpt(templ) site.posts = append(site.posts, templ.Metadata) // also add to tags index if tags, ok := templ.Metadata["tags"]; ok { for _, tag := range tags.([]interface{}) { tag := tag.(string) site.tags[tag] = append(site.tags[tag], templ.Metadata) } } } else { // the index pages should be skipped from the page directory filename := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) if filename != "index" { site.pages = append(site.pages, templ.Metadata) } } } site.templates[path] = templ } return nil }) if err != nil { return err } // sort posts by reverse chronological order Compare := func(a map[string]interface{}, b map[string]interface{}) int { return b["date"].(time.Time).Compare(a["date"].(time.Time)) } slices.SortFunc(site.posts, Compare) for _, posts := range site.tags { slices.SortFunc(posts, Compare) } return nil } func (site *Site) Build() error { // clear previous target contents os.RemoveAll(site.Config.TargetDir) os.Mkdir(site.Config.SrcDir, DIR_RWE_MODE) wg, files := spawnBuildWorkers(site) defer wg.Wait() defer close(files) // walk the source directory, creating directories and files at the target dir err := filepath.WalkDir(site.Config.SrcDir, func(path string, entry fs.DirEntry, err error) error { if err != nil { return err } subpath, _ := filepath.Rel(site.Config.SrcDir, path) targetPath := filepath.Join(site.Config.TargetDir, subpath) // if it's a directory, just create the same at the target if entry.IsDir() { return os.MkdirAll(targetPath, DIR_RWE_MODE) } // if it's a file (either static or template) send the path to a worker to build in target files <- path return nil }) return err } // Create a channel to send paths to build and a worker pool to handle them concurrently func spawnBuildWorkers(site *Site) (*sync.WaitGroup, chan string) { var wg sync.WaitGroup files := make(chan string, 20) for range runtime.NumCPU() { wg.Add(1) go func(files <-chan string) { defer wg.Done() for path := range files { site.buildFile(path) } }(files) } return &wg, files } func (site *Site) buildFile(path string) error { subpath, _ := filepath.Rel(site.Config.SrcDir, path) targetPath := filepath.Join(site.Config.TargetDir, subpath) var contentReader io.Reader var err error templ, found := site.templates[path] if !found { // if no template found at location, treat the file as static write its contents to target if site.Config.LinkStatic { // dev optimization: link static files instead of copying them abs, _ := filepath.Abs(path) err = os.Symlink(abs, targetPath) return checkFileError(err) } srcFile, err := os.Open(path) if err != nil { return checkFileError(err) } defer srcFile.Close() contentReader = srcFile } else { if templ.IsDraft() && !site.Config.IncludeDrafts { fmt.Println("skipping draft", targetPath) return nil } content, err := site.render(templ) if err != nil { return err } targetPath = strings.TrimSuffix(targetPath, filepath.Ext(targetPath)) + templ.TargetExt() contentReader = bytes.NewReader(content) } // post process file acording to extension and config targetExt := filepath.Ext(targetPath) contentReader, err = markup.Smartify(targetExt, contentReader) if err != nil { return err } contentReader, err = site.injectLiveReload(targetExt, contentReader) if err != nil { return err } if site.Config.Minify { contentReader = site.minifier.Minify(targetExt, contentReader) } // write the file contents over to target return writeToFile(targetPath, contentReader) } func (site *Site) render(templ *markup.Template) ([]byte, error) { ctx := map[string]interface{}{ "site": map[string]interface{}{ "config": site.Config.AsContext(), "posts": site.posts, "tags": site.tags, "pages": site.pages, "data": site.data, }, } ctx["page"] = templ.Metadata content, err := templ.RenderWith(ctx, site.Config.HighlightTheme) if err != nil { return nil, err } // recursively render parent layouts layout := templ.Metadata["layout"] for layout != nil && err == nil { if layout_templ, ok := site.layouts[layout.(string)]; ok { ctx["layout"] = layout_templ.Metadata ctx["content"] = content content, err = layout_templ.RenderWith(ctx, site.Config.HighlightTheme) if err != nil { return nil, err } layout = layout_templ.Metadata["layout"] } else { return nil, fmt.Errorf("layout '%s' not found", layout) } } return content, nil } func checkFileError(err error) error { // When walking the source dir it can happen that a file is present when walking starts // but missing or inaccessible when trying to open it (this is particularly frequent with // backup files from emacs and when running the dev server). We don't want to halt the build // process in that situation, just inform and continue. if os.IsNotExist(err) { // don't abort on missing files, usually spurious temps fmt.Println("skipping missing file", err) return nil } return err } func writeToFile(targetPath string, source io.Reader) error { targetFile, err := os.Create(targetPath) if err != nil { return err } defer targetFile.Close() _, err = io.Copy(targetFile, source) if err != nil { return err } fmt.Println("wrote", targetPath) return targetFile.Sync() } // Assuming the given template is a post, try to generating an excerpt of it. // If it contains an `excerpt` key in its metadata use that, otherwise try // to render it as HTML and extract the text of its first

func getExcerpt(templ *markup.Template) string { if excerpt, ok := templ.Metadata["excerpt"]; ok { return excerpt.(string) } // if we don't expect this to render to html don't bother parsing it if templ.TargetExt() != ".html" { return "" } content, err := templ.Render() if err != nil { return "" } return markup.ExtractFirstParagraph(bytes.NewReader(content)) } // if live reload is enabled, inject the reload snippet to html files func (site *Site) injectLiveReload(extension string, contentReader io.Reader) (io.Reader, error) { if !site.Config.LiveReload || extension != ".html" { return contentReader, nil } const JS_SNIPPET = ` const url = '%s/_events/' var eventSource; function newSSE() { console.log("connecting to server events"); eventSource = new EventSource(url); eventSource.onmessage = function () { location.reload() }; window.onbeforeunload = function() { eventSource.close(); } eventSource.onerror = function (event) { console.error('An error occurred:', event); eventSource.close(); setTimeout(newSSE, 5000) }; } newSSE();` script := fmt.Sprintf(JS_SNIPPET, site.Config.SiteUrl) return markup.InjectScript(contentReader, script) }