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}}