Implement Multi-Directory Watching and Enhance Locale File Handling

- Introduced the IWatchDirs interface to support watching multiple directories for changes, allowing templates to specify directories dynamically.
- Updated the watch command to handle multiple directories and display watched directories in the output.
- Added functionality to write locale files from a page's __locales directory to the public directory, improving localization support.
- Enhanced error handling during directory watching and locale file processing for better reliability and logging.
This commit is contained in:
Max 2026-01-03 14:39:57 +08:00
parent ebf3dcc870
commit 0825e06dbe
4 changed files with 295 additions and 1 deletions

View file

@ -99,7 +99,20 @@ var WatchCmd = &cobra.Command{
return
}
go watch(root, func(event, name string) {
// Get all directories to watch
watchDirs := []string{root}
if watchDirsProvider, ok := tmpl.(core.IWatchDirs); ok {
watchDirs = []string{}
watchRoot := cfg.DataRoot
if watchDirsProvider.GetWatchRoot() == "app" {
watchRoot = cfg.Root
}
for _, dir := range watchDirsProvider.GetWatchDirs() {
watchDirs = append(watchDirs, filepath.Join(watchRoot, dir))
}
}
go watchMultiple(watchDirs, func(event, name string) {
if event == "WRITE" || event == "CREATE" || event == "RENAME" {
// @Todo build single page and sync single asset file to public
fmt.Print(color.WhiteString("Building... "))
@ -134,6 +147,15 @@ var WatchCmd = &cobra.Command{
fmt.Println(color.WhiteString("Public Root: /public%s", publicRoot))
fmt.Println(color.WhiteString(" Template: %s", tmpl.GetRoot()))
fmt.Println(color.WhiteString(" Session: %s", strings.TrimLeft(data, "::")))
fmt.Println(color.WhiteString("Watch Dirs:"))
for _, dir := range watchDirs {
// Show path relative to either app root or data root
displayDir := strings.TrimPrefix(dir, cfg.Root)
if displayDir == dir {
displayDir = strings.TrimPrefix(dir, cfg.DataRoot)
}
fmt.Println(color.WhiteString(" - %s", displayDir))
}
fmt.Println(color.WhiteString("-----------------------"))
fmt.Println(color.GreenString("Watching..."))
fmt.Println(color.GreenString("Press Ctrl+C to exit"))
@ -151,6 +173,116 @@ var WatchCmd = &cobra.Command{
},
}
func watchMultiple(roots []string, handler func(event string, name string), interrupt chan uint8) error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
}
defer watcher.Close()
shutdown := make(chan bool, 1)
// Walk all root directories
watchedCount := 0
for _, root := range roots {
// Check if root exists
if _, err := os.Stat(root); os.IsNotExist(err) {
fmt.Println(color.YellowString("[Watch] Directory not found: %s", root))
continue
}
err = filepath.Walk(root, func(path string, info fs.FileInfo, err error) error {
if err != nil {
log.Warn("[Watch] Error accessing path %s: %v", path, err)
return nil // Skip this path and continue walking
}
if info.IsDir() {
if filepath.Base(path) == ".tmp" {
return filepath.SkipDir
}
err = watcher.Add(path)
if err != nil {
return err
}
watchedCount++
log.Info("[Watch] Watching: %s", path)
watched.Store(path, true)
}
return nil
})
if err != nil {
fmt.Println(color.YellowString("[Watch] Error walking root %s: %v", root, err))
}
}
fmt.Println(color.GreenString("[Watch] Total directories watched: %d", watchedCount))
go func() {
for {
select {
case <-shutdown:
log.Info("[Watch] handler exit")
return
case event, ok := <-watcher.Events:
if !ok {
interrupt <- 1
return
}
basname := filepath.Base(event.Name)
isdir := true
if strings.Contains(basname, ".") {
isdir = false
}
events := strings.Split(event.Op.String(), "|")
for _, eventType := range events {
// ADD / REMOVE Watching dir
if isdir {
switch eventType {
case "CREATE":
log.Info("[Watch] Watching: %s", event.Name)
watcher.Add(event.Name)
watched.Store(event.Name, true)
break
case "REMOVE":
log.Info("[Watch] Unwatching: %s", event.Name)
watcher.Remove(event.Name)
watched.Delete(event.Name)
break
}
continue
}
handler(eventType, event.Name)
log.Info("[Watch] %s %s", eventType, event.Name)
}
break
case err, ok := <-watcher.Errors:
if !ok {
interrupt <- 2
return
}
log.Error("[Watch] Error: %s", err.Error())
break
}
}
}()
for {
select {
case code := <-interrupt:
shutdown <- true
log.Info("[Watch] Exit(%d)", code)
fmt.Println(color.YellowString("[Watch] Exit(%d)", code))
return nil
}
}
}
func watch(root string, handler func(event string, name string), interrupt chan uint8) error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
@ -160,6 +292,10 @@ func watch(root string, handler func(event string, name string), interrupt chan
shutdown := make(chan bool, 1)
err = filepath.Walk(root, func(path string, info fs.FileInfo, err error) error {
if err != nil {
log.Warn("[Watch] Error accessing path %s: %v", path, err)
return nil // Skip this path and continue walking
}
if info.IsDir() {
if filepath.Base(path) == ".tmp" {
return filepath.SkipDir

View file

@ -118,3 +118,13 @@ type IComponent interface {
Load() error
Source() string
}
// IWatchDirs is an optional interface for templates that need to watch multiple directories
type IWatchDirs interface {
// GetWatchDirs returns all directories that should be watched for changes
// The returned paths are relative to the application source root (not data root)
GetWatchDirs() []string
// GetWatchRoot returns the root directory for watch paths
// Returns "app" for application source root, "data" for data root
GetWatchRoot() string
}

View file

@ -11,6 +11,7 @@ import (
v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/sui/core"
"gopkg.in/yaml.v3"
)
// Page wraps core.Page with agent-specific functionality
@ -315,6 +316,13 @@ func (page *Page) Build(globalCtx *core.GlobalBuildContext, option *core.BuildOp
return warnings, err
}
// Write locale files from page's __locales directory
err = page.writeLocaleFiles(option.Data)
if err != nil {
log.Warn("[Agent] Write locale files error: %s", err.Error())
// Don't fail the build for locale errors
}
return warnings, nil
}
@ -465,3 +473,116 @@ func (page *Page) AssetRoot() string {
func (page *Page) AssistantID() string {
return page.assistantID
}
// writeLocaleFiles writes locale files from page's __locales directory to public
func (page *Page) writeLocaleFiles(data map[string]interface{}) error {
fs := page.tmpl.agent.fs
// Check if page has __locales directory
localesDir := filepath.Join(page.Path, "__locales")
if !fs.IsDir(localesDir) {
return nil
}
// Get the public root
root, err := page.tmpl.agent.DSL.PublicRoot(data)
if err != nil {
log.Error("writeLocaleFiles: Get the public root error: %s. use %s", err.Error(), page.tmpl.agent.DSL.Public.Root)
root = page.tmpl.agent.DSL.Public.Root
}
// Read all locale files in __locales directory
files, err := fs.ReadDir(localesDir, false)
if err != nil {
return err
}
for _, file := range files {
// Skip directories
if fs.IsDir(file) {
continue
}
// Only process .yml files
if filepath.Ext(file) != ".yml" {
continue
}
// Get locale name (e.g., "zh-cn" from "zh-cn.yml")
localeName := filepath.Base(file)
localeName = localeName[:len(localeName)-4] // Remove .yml extension
// Read the locale file
content, err := fs.ReadFile(file)
if err != nil {
log.Error("[Agent] Read locale file error: %s", err.Error())
continue
}
// Parse the locale file
var localeData map[string]interface{}
err = yaml.Unmarshal(content, &localeData)
if err != nil {
log.Error("[Agent] Parse locale file error: %s", err.Error())
continue
}
// Convert to the format expected by core.Locale
locale := core.Locale{
Name: localeName,
Keys: map[string]string{},
Messages: map[string]string{},
ScriptMessages: map[string]string{},
}
// Extract messages
if messages, ok := localeData["messages"].(map[string]interface{}); ok {
for k, v := range messages {
if strVal, ok := v.(string); ok {
locale.Messages[k] = strVal
}
}
}
// Extract script_messages
if scriptMessages, ok := localeData["script_messages"].(map[string]interface{}); ok {
for k, v := range scriptMessages {
if strVal, ok := v.(string); ok {
locale.ScriptMessages[k] = strVal
}
}
}
// Extract timezone and direction
if tz, ok := localeData["timezone"].(string); ok {
locale.Timezone = tz
}
if dir, ok := localeData["direction"].(string); ok {
locale.Direction = dir
}
// Write to public/.locales/<locale>/<route>.yml
// page.Route may contain path like /expense/test, so we need to create nested directories
targetFile := filepath.Join(application.App.Root(), "public", root, ".locales", localeName, fmt.Sprintf("%s.yml", page.Route))
targetDir := filepath.Dir(targetFile)
if exist, _ := os.Stat(targetDir); exist == nil {
os.MkdirAll(targetDir, os.ModePerm)
}
localeContent, err := yaml.Marshal(locale)
if err != nil {
log.Error("[Agent] Marshal locale error: %s", err.Error())
continue
}
err = os.WriteFile(targetFile, localeContent, 0644)
if err != nil {
log.Error("[Agent] Write locale file error: %s", err.Error())
continue
}
log.Info("[Agent] Wrote locale file: %s", targetFile)
}
return nil
}

View file

@ -226,6 +226,33 @@ func (tmpl *Template) GetRoot() string {
return tmpl.agent.root
}
// GetWatchDirs returns all directories that should be watched for changes
// This implements the core.IWatchDirs interface
func (tmpl *Template) GetWatchDirs() []string {
dirs := []string{}
// 1. Add the main agent template directory
dirs = append(dirs, tmpl.agent.root)
// 2. Add each assistant's pages directory
assistants, err := tmpl.agent.getAssistants()
if err != nil {
return dirs
}
for _, assistantID := range assistants {
pagesDir := tmpl.agent.getAssistantPagesRoot(assistantID)
dirs = append(dirs, pagesDir)
}
return dirs
}
// GetWatchRoot returns "app" to indicate paths are relative to application source root
func (tmpl *Template) GetWatchRoot() string {
return "app"
}
// Asset get the asset (check agent assets first, then assistant assets)
func (tmpl *Template) Asset(file string, width, height uint) (*core.Asset, error) {
// First check in agent assets