diff --git a/.env.example b/.env.example
new file mode 100644
index 000000000..b08736cdf
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,36 @@
+# Installation
+INSTALL_PORT=80
+AUTO_INSTALL=false
+
+# Database
+DB_TYPE=
+DB_USERNAME=
+DB_PASSWORD=
+DB_HOST=
+DB_NAME=
+DB_FILE=
+
+# Site
+LANGUAGE=en-US
+SITE_NAME=Apache Answer
+SITE_URL=
+CONTACT_EMAIL=
+
+# Admin
+ADMIN_NAME=
+ADMIN_PASSWORD=
+ADMIN_EMAIL=
+
+# Content
+EXTERNAL_CONTENT_DISPLAY=ask_before_display
+
+# Swagger
+SWAGGER_HOST=
+SWAGGER_ADDRESS_PORT=
+
+# Server
+SITE_ADDR=0.0.0.0:3000
+
+# Logging
+LOG_LEVEL=INFO
+LOG_PATH=
diff --git a/.gitignore b/.gitignore
index 257ef31d6..ba66f51a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,6 @@ dist/
# Lint setup generated file
.husky/
+
+# Environment variables
+.env
\ No newline at end of file
diff --git a/cmd/main.go b/cmd/main.go
index f166d2309..1f8153001 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -31,12 +31,18 @@ import (
"github.com/apache/answer/internal/base/path"
"github.com/apache/answer/internal/schema"
"github.com/gin-gonic/gin"
+ "github.com/joho/godotenv"
"github.com/segmentfault/pacman"
"github.com/segmentfault/pacman/contrib/log/zap"
"github.com/segmentfault/pacman/contrib/server/http"
"github.com/segmentfault/pacman/log"
)
+func init() {
+ // Load .env if present, ignore error to keep backward compatibility
+ _ = godotenv.Load()
+}
+
// go build -ldflags "-X github.com/apache/answer/cmd.Version=x.y.z"
var (
// Name is the name of the project
diff --git a/go.mod b/go.mod
index 969d9ebbc..52d64c738 100644
--- a/go.mod
+++ b/go.mod
@@ -117,6 +117,7 @@ require (
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/joho/godotenv v1.5.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
diff --git a/go.sum b/go.sum
index 35db004db..69d97751e 100644
--- a/go.sum
+++ b/go.sum
@@ -354,6 +354,8 @@ github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg=
github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
diff --git a/internal/base/handler/lang.go b/internal/base/handler/lang.go
index 8886f0631..202449e9d 100644
--- a/internal/base/handler/lang.go
+++ b/internal/base/handler/lang.go
@@ -23,11 +23,22 @@ import (
"context"
"github.com/apache/answer/internal/base/constant"
+ "github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/i18n"
)
// GetLangByCtx get language from header
func GetLangByCtx(ctx context.Context) i18n.Language {
+ if ginCtx, ok := ctx.(*gin.Context); ok {
+ acceptLanguage, ok := ginCtx.Get(constant.AcceptLanguageFlag)
+ if ok {
+ if acceptLanguage, ok := acceptLanguage.(i18n.Language); ok {
+ return acceptLanguage
+ }
+ return i18n.DefaultLanguage
+ }
+ }
+
acceptLanguage, ok := ctx.Value(constant.AcceptLanguageContextKey).(i18n.Language)
if ok {
return acceptLanguage
diff --git a/internal/base/translator/provider.go b/internal/base/translator/provider.go
index 47212e84f..1a465b1e8 100644
--- a/internal/base/translator/provider.go
+++ b/internal/base/translator/provider.go
@@ -23,6 +23,8 @@ import (
"fmt"
"os"
"path/filepath"
+ "sort"
+ "strings"
"github.com/google/wire"
myTran "github.com/segmentfault/pacman/contrib/i18n"
@@ -100,6 +102,7 @@ func NewTranslator(c *I18n) (tr i18n.Translator, err error) {
// add translator use backend translation
if err = myTran.AddTranslator(content, file.Name()); err != nil {
log.Debugf("add translator failed: %s %s", file.Name(), err)
+ reportTranslatorFormatError(file.Name(), buf)
continue
}
}
@@ -160,3 +163,165 @@ func TrWithData(lang i18n.Language, key string, templateData any) string {
}
return translation
}
+
+// reportTranslatorFormatError re-parses the YAML file to locate the invalid entry
+// when go-i18n fails to add the translator.
+func reportTranslatorFormatError(fileName string, content []byte) {
+ var raw any
+ if err := yaml.Unmarshal(content, &raw); err != nil {
+ log.Errorf("parse translator file %s failed when diagnosing format error: %s", fileName, err)
+ return
+ }
+ if err := inspectTranslatorNode(raw, nil, true); err != nil {
+ log.Errorf("translator file %s invalid: %s", fileName, err)
+ }
+}
+
+func inspectTranslatorNode(node any, path []string, isRoot bool) error {
+ switch data := node.(type) {
+ case nil:
+ if isRoot {
+ return fmt.Errorf("root value is empty")
+ }
+ return fmt.Errorf("%s contains an empty value", formatTranslationPath(path))
+ case string:
+ if isRoot {
+ return fmt.Errorf("root value must be an object but found string")
+ }
+ return nil
+ case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
+ if isRoot {
+ return fmt.Errorf("root value must be an object but found %T", data)
+ }
+ return fmt.Errorf("%s expects a string translation but found %T", formatTranslationPath(path), data)
+ case map[string]any:
+ if isMessageMap(data) {
+ return nil
+ }
+ keys := make([]string, 0, len(data))
+ for key := range data {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ for _, key := range keys {
+ if err := inspectTranslatorNode(data[key], append(path, key), false); err != nil {
+ return err
+ }
+ }
+ return nil
+ case map[string]string:
+ mapped := make(map[string]any, len(data))
+ for k, v := range data {
+ mapped[k] = v
+ }
+ return inspectTranslatorNode(mapped, path, isRoot)
+ case map[any]any:
+ if isMessageMap(data) {
+ return nil
+ }
+ type kv struct {
+ key string
+ val any
+ }
+ items := make([]kv, 0, len(data))
+ for key, val := range data {
+ strKey, ok := key.(string)
+ if !ok {
+ return fmt.Errorf("%s uses a non-string key %#v", formatTranslationPath(path), key)
+ }
+ items = append(items, kv{key: strKey, val: val})
+ }
+ sort.Slice(items, func(i, j int) bool {
+ return items[i].key < items[j].key
+ })
+ for _, item := range items {
+ if err := inspectTranslatorNode(item.val, append(path, item.key), false); err != nil {
+ return err
+ }
+ }
+ return nil
+ case []any:
+ for idx, child := range data {
+ nextPath := append(path, fmt.Sprintf("[%d]", idx))
+ if err := inspectTranslatorNode(child, nextPath, false); err != nil {
+ return err
+ }
+ }
+ return nil
+ case []map[string]any:
+ for idx, child := range data {
+ nextPath := append(path, fmt.Sprintf("[%d]", idx))
+ if err := inspectTranslatorNode(child, nextPath, false); err != nil {
+ return err
+ }
+ }
+ return nil
+ default:
+ if isRoot {
+ return fmt.Errorf("root value must be an object but found %T", data)
+ }
+ return fmt.Errorf("%s contains unsupported value type %T", formatTranslationPath(path), data)
+ }
+}
+
+var translatorReservedKeys = []string{
+ "id", "description", "hash", "leftdelim", "rightdelim",
+ "zero", "one", "two", "few", "many", "other",
+}
+
+func isMessageMap(data any) bool {
+ switch v := data.(type) {
+ case map[string]any:
+ for _, key := range translatorReservedKeys {
+ val, ok := v[key]
+ if !ok {
+ continue
+ }
+ if _, ok := val.(string); ok {
+ return true
+ }
+ }
+ case map[string]string:
+ for _, key := range translatorReservedKeys {
+ val, ok := v[key]
+ if !ok {
+ continue
+ }
+ if val != "" {
+ return true
+ }
+ }
+ case map[any]any:
+ for _, key := range translatorReservedKeys {
+ val, ok := v[key]
+ if !ok {
+ continue
+ }
+ if _, ok := val.(string); ok {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func formatTranslationPath(path []string) string {
+ if len(path) == 0 {
+ return "root"
+ }
+ var b strings.Builder
+ for _, part := range path {
+ if part == "" {
+ continue
+ }
+ if part[0] == '[' {
+ b.WriteString(part)
+ continue
+ }
+ if b.Len() > 0 {
+ b.WriteByte('.')
+ }
+ b.WriteString(part)
+ }
+ return b.String()
+}
diff --git a/internal/install/install_main.go b/internal/install/install_main.go
index 41ccdcf40..eae914af0 100644
--- a/internal/install/install_main.go
+++ b/internal/install/install_main.go
@@ -25,13 +25,20 @@ import (
"github.com/apache/answer/internal/base/path"
"github.com/apache/answer/internal/base/translator"
+ "github.com/joho/godotenv"
)
var (
- port = os.Getenv("INSTALL_PORT")
+ port string
confPath = ""
)
+func init() {
+ _ = godotenv.Load()
+ port = os.Getenv("INSTALL_PORT")
+
+}
+
func Run(configPath string) {
confPath = configPath
// initialize translator for return internationalization error when installing.
diff --git a/ui/src/pages/SideNavLayout/index.tsx b/ui/src/pages/SideNavLayout/index.tsx
index 907b9b281..b9bc38b9c 100644
--- a/ui/src/pages/SideNavLayout/index.tsx
+++ b/ui/src/pages/SideNavLayout/index.tsx
@@ -38,7 +38,7 @@ const Index: FC = () => {