diff --git a/Dockerfile b/Dockerfile index 6669d4d..e2bad33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ -FROM golang:1.18 +FROM golang:1.26 ADD . /code WORKDIR /code RUN go install EXPOSE 8000 -CMD ["ink", "preview", "template"] \ No newline at end of file +CMD ["ink", "preview", "template"] diff --git a/api.go b/api.go index 50ed278..6d1ac71 100644 --- a/api.go +++ b/api.go @@ -36,7 +36,7 @@ func hashPath(path string) string { return hex.EncodeToString(md5Hex[:]) } -func replyJSON(w http.ResponseWriter, status int, data interface{}) { +func replyJSON(w http.ResponseWriter, status int, data any) { jsonStr, err := json.Marshal(data) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/build.go b/build.go index bf03099..9fef43c 100755 --- a/build.go +++ b/build.go @@ -15,9 +15,6 @@ import ( var articleTpl, pageTpl, archiveTpl, tagTpl template.Template var themePath, publicPath, sourcePath string -// For concurrency -var wg sync.WaitGroup - // Data struct type ArticleInfo struct { DetailDate int64 @@ -41,7 +38,7 @@ type Tag struct { } // For sort -type Collections []interface{} +type Collections []any func (v Collections) Len() int { return len(v) } func (v Collections) Swap(i, j int) { v[i], v[j] = v[j], v[i] } @@ -72,6 +69,7 @@ func (v Collections) Less(i, j int) bool { func Build() { startTime := time.Now() + var tasks sync.WaitGroup var articles = make(Collections, 0) var visibleArticles = make(Collections, 0) var pages = make(Collections, 0) @@ -82,7 +80,7 @@ func Build() { publicPath = filepath.Join(rootPath, globalConfig.Build.Output) sourcePath = filepath.Join(rootPath, "source") // Append all partial html - var partialTpl string + var partialTpl strings.Builder files, err := filepath.Glob(filepath.Join(themePath, "*.html")) if err != nil { Fatal(err.Error()) @@ -98,7 +96,7 @@ func Build() { tplName := strings.TrimPrefix(baseName, "_") tplName = strings.TrimSuffix(tplName, ".html") htmlStr := "{{define \"" + tplName + "\"}}" + string(html) + "{{end}}" - partialTpl += htmlStr + partialTpl.WriteString(htmlStr) } } // Compile template @@ -109,10 +107,10 @@ func Build() { global: globalConfig, currentCwd: themePath, } - articleTpl = CompileTpl(filepath.Join(themePath, "article.html"), partialTpl, "article", funcCxt) - pageTpl = CompileTpl(filepath.Join(themePath, "page.html"), partialTpl, "page", funcCxt) - archiveTpl = CompileTpl(filepath.Join(themePath, "archive.html"), partialTpl, "archive", funcCxt) - tagTpl = CompileTpl(filepath.Join(themePath, "tag.html"), partialTpl, "tag", funcCxt) + articleTpl = CompileTpl(filepath.Join(themePath, "article.html"), partialTpl.String(), "article", funcCxt) + pageTpl = CompileTpl(filepath.Join(themePath, "page.html"), partialTpl.String(), "page", funcCxt) + archiveTpl = CompileTpl(filepath.Join(themePath, "archive.html"), partialTpl.String(), "archive", funcCxt) + tagTpl = CompileTpl(filepath.Join(themePath, "tag.html"), partialTpl.String(), "tag", funcCxt) // Clean public folder cleanPatterns := []string{"post", "tag", "images", "js", "css", "*.html", "favicon.ico", "robots.txt"} for _, pattern := range cleanPatterns { @@ -189,28 +187,21 @@ func Build() { sort.Sort(articles) sort.Sort(visibleArticles) // Generate RSS page - wg.Add(1) - go GenerateRSS(visibleArticles) + tasks.Go(func() { GenerateRSS(visibleArticles) }) // Generate sitemap page - wg.Add(1) - go GenerateSitemap(visibleArticles) + tasks.Go(func() { GenerateSitemap(visibleArticles) }) // Generate article list JSON - wg.Add(1) - go GenerateJSON(visibleArticles) + tasks.Go(func() { GenerateJSON(visibleArticles) }) // Render articles - wg.Add(1) - go RenderArticles(articleTpl, articles) + RenderArticles(&tasks, articleTpl, articles) // Render pages - wg.Add(1) - go RenderArticles(articleTpl, pages) + RenderArticles(&tasks, articleTpl, pages) // Generate article list pages - wg.Add(1) - go RenderArticleList("", visibleArticles, "") + RenderArticleList(&tasks, "", visibleArticles, "") // Generate article list pages by tag for tagName, articles := range tagMap { sort.Sort(articles) - wg.Add(1) - go RenderArticleList(filepath.Join("tag", tagName), articles, tagName) + RenderArticleList(&tasks, filepath.Join("tag", tagName), articles, tagName) } // Generate archive page archives := make(Collections, 0) @@ -224,13 +215,14 @@ func Build() { } // Sort by year sort.Sort(archives) - wg.Add(1) - go RenderPage(archiveTpl, map[string]interface{}{ - "Total": len(visibleArticles), - "Archive": archives, - "Site": globalConfig.Site, - "I18n": globalConfig.I18n, - }, filepath.Join(publicPath, "archive.html")) + tasks.Go(func() { + RenderPage(archiveTpl, map[string]any{ + "Total": len(visibleArticles), + "Archive": archives, + "Site": globalConfig.Site, + "I18n": globalConfig.I18n, + }, filepath.Join(publicPath, "archive.html")) + }) // Generate tag page tags := make(Collections, 0) for tagName, tagArticles := range tagMap { @@ -257,13 +249,14 @@ func Build() { } // Sort by count sort.Sort(tags) - wg.Add(1) - go RenderPage(tagTpl, map[string]interface{}{ - "Total": len(visibleArticles), - "Tag": tags, - "Site": globalConfig.Site, - "I18n": globalConfig.I18n, - }, filepath.Join(publicPath, "tag.html")) + tasks.Go(func() { + RenderPage(tagTpl, map[string]any{ + "Total": len(visibleArticles), + "Tag": tags, + "Site": globalConfig.Site, + "I18n": globalConfig.I18n, + }, filepath.Join(publicPath, "tag.html")) + }) // Generate other pages files, err = filepath.Glob(filepath.Join(sourcePath, "*.html")) if err != nil { @@ -280,25 +273,26 @@ func Build() { fileExt := strings.ToLower(filepath.Ext(path)) baseName := filepath.Base(path) if fileExt == ".html" && !strings.HasPrefix(baseName, "_") { - htmlTpl := CompileTpl(path, partialTpl, baseName, funcCxt) + htmlTpl := CompileTpl(path, partialTpl.String(), baseName, funcCxt) relPath, err := filepath.Rel(sourcePath, path) if err != nil { Fatal(err.Error()) } - wg.Add(1) - go RenderPage(htmlTpl, globalConfig, filepath.Join(publicPath, relPath)) + tasks.Go(func() { + RenderPage(htmlTpl, globalConfig, filepath.Join(publicPath, relPath)) + }) } } // Copy static files - Copy() - wg.Wait() + Copy(&tasks) + tasks.Wait() endTime := time.Now() usedTime := endTime.Sub(startTime) fmt.Printf("\nFinished to build in public folder (%v)\n", usedTime) } // Copy static files -func Copy() { +func Copy(tasks *sync.WaitGroup) { srcList := globalConfig.Build.Copy for _, source := range srcList { if matches, err := filepath.Glob(filepath.Join(rootPath, source)); err == nil { @@ -310,11 +304,10 @@ func Copy() { } fileName := file.Name() desPath := filepath.Join(publicPath, fileName) - wg.Add(1) if file.IsDir() { - go CopyDir(srcPath, desPath) + tasks.Go(func() { CopyDir(srcPath, desPath) }) } else { - go CopyFile(srcPath, desPath) + tasks.Go(func() { CopyFile(srcPath, desPath) }) } } } else { diff --git a/go.mod b/go.mod index 9687e39..5ce6893 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/InkProject/ink -go 1.25.0 +go 1.26.0 require ( github.com/fsnotify/fsnotify v1.10.1 diff --git a/parse.go b/parse.go index 8e831e8..467cfd1 100755 --- a/parse.go +++ b/parse.go @@ -27,7 +27,7 @@ type SiteConfig struct { Lang string Url string Link string - Config interface{} + Config any } type AuthorConfig struct { @@ -70,7 +70,7 @@ type ArticleConfig struct { Toc bool Image string Subtitle string - Config map[string]interface{} + Config map[string]any } type Article struct { @@ -87,7 +87,7 @@ type Article struct { Preview template.HTML Content template.HTML Link string - Config interface{} + Config any Image string Subtitle string } @@ -249,7 +249,7 @@ func ParseArticle(markdownPath string) *Article { return nil } if config.Config == nil { - config.Config = make(map[string]interface{}) + config.Config = make(map[string]any) } var article Article // Parse markdown content diff --git a/render.go b/render.go index 664d429..559948b 100755 --- a/render.go +++ b/render.go @@ -7,13 +7,14 @@ import ( "os" "path/filepath" "strconv" + "sync" "time" "github.com/gorilla/feeds" "github.com/snabb/sitemap" ) -type Data interface{} +type Data any type RenderArticle struct { Article @@ -39,7 +40,7 @@ func CompileTpl(tplPath string, partialTpl string, name string, funcContext Func } // Render html file by data -func RenderPage(tpl template.Template, tplData interface{}, outPath string) { +func RenderPage(tpl template.Template, tplData any, outPath string) { // Create file outFile, err := os.Create(outPath) if err != nil { @@ -50,7 +51,6 @@ func RenderPage(tpl template.Template, tplData interface{}, outPath string) { Fatal(err.Error()) } }() - defer wg.Done() // Template render err = tpl.Execute(outFile, tplData) if err != nil { @@ -59,8 +59,7 @@ func RenderPage(tpl template.Template, tplData interface{}, outPath string) { } // Generate all article page -func RenderArticles(tpl template.Template, articles Collections) { - defer wg.Done() +func RenderArticles(tasks *sync.WaitGroup, tpl template.Template, articles Collections) { articleCount := len(articles) for i := range articles { currentArticle := articles[i].(Article) @@ -89,14 +88,12 @@ func RenderArticles(tpl template.Template, articles Collections) { } } outPath := filepath.Join(publicPath, currentArticle.Link) - wg.Add(1) - go RenderPage(tpl, renderArticle, outPath) + tasks.Go(func() { RenderPage(tpl, renderArticle, outPath) }) } } // Generate rss page func GenerateRSS(articles Collections) { - defer wg.Done() var feedArticles Collections if len(articles) < globalConfig.Site.Limit { feedArticles = articles @@ -136,8 +133,6 @@ func GenerateRSS(articles Collections) { // Generate sitemap page func GenerateSitemap(articles Collections) { - defer wg.Done() - if globalConfig.Site.Url != "" { sm := sitemap.New() @@ -177,8 +172,7 @@ func GenerateSitemap(articles Collections) { } // Generate article list page -func RenderArticleList(rootPath string, articles Collections, tagName string) { - defer wg.Done() +func RenderArticleList(tasks *sync.WaitGroup, rootPath string, articles Collections, tagName string) { // Create path pagePath := filepath.Join(publicPath, rootPath) if err := os.MkdirAll(pagePath, 0777); err != nil { @@ -216,7 +210,7 @@ func RenderArticleList(rootPath string, articles Collections, tagName string) { } next = "" } - var data = map[string]interface{}{ + var data = map[string]any{ "Articles": articles[first:count], "Site": globalConfig.Site, "Develop": globalConfig.Develop, @@ -227,18 +221,16 @@ func RenderArticleList(rootPath string, articles Collections, tagName string) { "TagName": tagName, "TagCount": len(articles), } - wg.Add(1) - go RenderPage(pageTpl, data, outPath) + tasks.Go(func() { RenderPage(pageTpl, data, outPath) }) } } // Generate article list JSON func GenerateJSON(articles Collections) { - defer wg.Done() - datas := make([]map[string]interface{}, 0) + datas := make([]map[string]any, 0) for i := range articles { article := articles[i].(Article) - var data = map[string]interface{}{ + var data = map[string]any{ "title": article.Title, "content": article.Markdown, "preview": string(article.Preview), diff --git a/serve.go b/serve.go index f10f11e..41ad0e9 100644 --- a/serve.go +++ b/serve.go @@ -1,31 +1,58 @@ package main import ( + "errors" + "fmt" "net/http" "os" "path/filepath" - "reflect" + "sync" + "time" "github.com/fsnotify/fsnotify" "github.com/gorilla/websocket" ) var watcher *fsnotify.Watcher -var conn *websocket.Conn +var reloadMu sync.Mutex +var reloadClients = make(map[*websocket.Conn]struct{}) + +const watchDebounce = 150 * time.Millisecond +const reloadWriteTimeout = 5 * time.Second + +func notifyReloadClients() { + reloadMu.Lock() + defer reloadMu.Unlock() + for client := range reloadClients { + err := client.SetWriteDeadline(time.Now().Add(reloadWriteTimeout)) + if err == nil { + err = client.WriteMessage(websocket.TextMessage, []byte("change")) + } + if err == nil { + continue + } + Warn(err.Error()) + if err := client.Close(); err != nil { + Warn(err.Error()) + } + delete(reloadClients, client) + } +} func buildWatchList() (files []string, dirs []string) { + configuredThemePath := filepath.Join(rootPath, globalConfig.Site.Theme) dirs = []string{ filepath.Join(rootPath, "source"), } files = []string{ filepath.Join(rootPath, "config.yml"), - themePath, + configuredThemePath, } // Add files and directories defined in theme's config.yml to watcher for _, themeCopiedPath := range themeConfig.Copy { if themeCopiedPath != "" { - fullPath := filepath.Join(themePath, themeCopiedPath) + fullPath := filepath.Join(configuredThemePath, themeCopiedPath) s, err := os.Stat(fullPath) if s == nil || err != nil { continue @@ -41,26 +68,66 @@ func buildWatchList() (files []string, dirs []string) { return files, dirs } -// Add files and dirs to watcher -func configureWatcher(watcher *fsnotify.Watcher, files []string, dirs []string) { +// Make the active watch set exactly match the current configuration. +func configureWatcher(watcher *fsnotify.Watcher) error { + files, dirs := buildWatchList() + desired := make(map[string]struct{}) for _, source := range dirs { if err := walkSymlinks(source, func(path string, f os.FileInfo, err error) error { if err != nil { - Warn(err.Error()) - return nil + return err } if f != nil && f.IsDir() { - if err := watcher.Add(path); err != nil { - Warn(err.Error()) - } + desired[filepath.Clean(path)] = struct{}{} } return nil }); err != nil { - Warn(err.Error()) + return err } } for _, source := range files { - if err := watcher.Add(source); err != nil { + desired[filepath.Clean(source)] = struct{}{} + } + for _, path := range watcher.WatchList() { + path = filepath.Clean(path) + if _, ok := desired[path]; ok { + delete(desired, path) + continue + } + if err := watcher.Remove(path); err != nil && !errors.Is(err, fsnotify.ErrNonExistentWatch) { + return fmt.Errorf("remove watch %q: %w", path, err) + } + } + for path := range desired { + if err := watcher.Add(path); err != nil { + return fmt.Errorf("add watch %q: %w", path, err) + } + } + return nil +} + +func watchEvents(watcher *fsnotify.Watcher, rebuild func()) { + timer := time.NewTimer(watchDebounce) + timer.Stop() + defer timer.Stop() + + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) && !event.Has(fsnotify.Remove) && !event.Has(fsnotify.Rename) { + continue + } + Log(event.Name) + timer.Reset(watchDebounce) + case <-timer.C: + rebuild() + case err, ok := <-watcher.Errors: + if !ok { + return + } Warn(err.Error()) } } @@ -77,38 +144,26 @@ func Watch() { if err != nil { Fatal(err.Error()) } + if err := configureWatcher(newWatcher); err != nil { + if closeErr := newWatcher.Close(); closeErr != nil { + Warn(closeErr.Error()) + } + Fatal(err.Error()) + } watcher = newWatcher - files, dirs := buildWatchList() - go func() { - for { - select { - case event := <-watcher.Events: - if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) { - // Handle when file change - Log(event.Name) - ParseGlobalConfigWrap(rootPath, true) - - newFiles, newDirs := buildWatchList() - // If file list changed, reconfigure watcher - if !reflect.DeepEqual(files, newFiles) || !reflect.DeepEqual(dirs, newDirs) { - configureWatcher(watcher, newFiles, newDirs) - files = newFiles - dirs = newDirs - } - - Build() - if conn != nil { - if err := conn.WriteMessage(websocket.TextMessage, []byte("change")); err != nil { - Warn(err.Error()) - } - } - } - case err := <-watcher.Errors: - Warn(err.Error()) - } + go watchEvents(newWatcher, func() { + ParseGlobalConfigWrap(rootPath, true) + if globalConfig == nil || themeConfig == nil { + Warn("Parse config.yml failed; waiting for another change") + return + } + if err := configureWatcher(newWatcher); err != nil { + Warn(err.Error()) + return } - }() - configureWatcher(watcher, files, dirs) + Build() + notifyReloadClients() + }) } func Websocket(w http.ResponseWriter, r *http.Request) { @@ -119,7 +174,9 @@ func Websocket(w http.ResponseWriter, r *http.Request) { if c, err := upgrader.Upgrade(w, r, nil); err != nil { Warn(err) } else { - conn = c + reloadMu.Lock() + reloadClients[c] = struct{}{} + reloadMu.Unlock() } } diff --git a/template/theme/_head.html b/template/theme/_head.html index 40d464f..361e3cf 100644 --- a/template/theme/_head.html +++ b/template/theme/_head.html @@ -52,8 +52,7 @@ var root = '{{.Site.Root}}'; - - + {{if .Develop}}