]> git.earlybird.gay Git - today/commitdiff
Lay out localization for pages
authorearly <me@earlybird.gay>
Mon, 25 Nov 2024 11:59:15 +0000 (04:59 -0700)
committerearly <me@earlybird.gay>
Mon, 25 Nov 2024 11:59:15 +0000 (04:59 -0700)
23 files changed:
app/app.go
include/callstack.go
include/include.go
include/localized.go [new file with mode: 0644]
landing-page/.languages/ca.po [new file with mode: 0644]
landing-page/.languages/en.po [new file with mode: 0644]
landing-page/ca/bloc/correu.html [new file with mode: 0644]
landing-page/ca/bloc/index.html [new file with mode: 0644]
landing-page/ca/index.html [new file with mode: 0644]
landing-page/en/blog/index.html [new file with mode: 0644]
landing-page/en/blog/post.go [new file with mode: 0644]
landing-page/en/blog/post.html [new file with mode: 0644]
landing-page/en/index.html [new file with mode: 0644]
landing-page/main.go [new file with mode: 0644]
localization/http.go [new file with mode: 0644]
localization/languages.go [new file with mode: 0644]
localization/po/po.go [new file with mode: 0644]
web/internal/compile/compile.go
web/internal/compile/component.go
web/internal/compile/template.go
web/page/page.go
web/page/serve.go
web/part/part.go

index 8d8281298a43abec6dcf09dc752acbb7ba1833ad..eb6ab84d4bfa774d36193f33ded5e5170fb87f97 100644 (file)
@@ -19,6 +19,7 @@ import (
        "sync/atomic"
 
        "git.earlybird.gay/today/include"
+       "git.earlybird.gay/today/localization"
        "git.earlybird.gay/today/web/page"
 )
 
@@ -29,9 +30,11 @@ func init() {
 type App struct {
        http.Server
        http.ServeMux
+       handler http.Handler
 
        Logger *slog.Logger
 
+       preHandler     []func(next http.Handler) http.Handler
        static         map[string]http.Handler
        nonStaticFiles []string
 
@@ -47,6 +50,7 @@ func New() *App {
        app := new(App)
        app.shutdown = make(chan struct{})
        app.stop = make(chan struct{})
+       app.handler = &app.ServeMux
        return app
 }
 
@@ -196,7 +200,6 @@ func (app *App) Static(rootPath, rootDir string) error {
                        return err
                }
                if stat.IsDir() {
-                       // Recurse to subdirectories
                        entries, err := os.ReadDir(fpath)
                        if err != nil {
                                return err
@@ -252,9 +255,35 @@ func (app *App) Static(rootPath, rootDir string) error {
 func (app *App) Handle(expr string, handler http.Handler) {
        // Check page handlers against static
        if todayPage, ok := handler.(*page.Page); ok {
+               // Special handling for Today pages
                app.nonStaticFiles = append(app.nonStaticFiles, todayPage.FileDependencies()...)
+               for _, lang := range todayPage.Languages() {
+                       translatedExpr := expr
+                       translations, ok := todayPage.Translations()[lang]
+                       if ok {
+                               translatedExpr = localization.TranslatePath(todayPage.DefaultLanguage(), lang, expr, translations)
+                       }
+
+                       for _, middleware := range app.preHandler {
+                               handler = middleware(handler)
+                       }
+                       app.ServeMux.Handle(translatedExpr, handler)
+               }
+       } else {
+               // Normal things just get passed through
+               for _, middleware := range app.preHandler {
+                       handler = middleware(handler)
+               }
+               app.ServeMux.Handle(expr, handler)
        }
-       app.ServeMux.Handle(expr, handler)
+}
+
+func (app *App) PreRouting(create func(next http.Handler) http.Handler) {
+       app.handler = create(app.handler)
+}
+
+func (app *App) PreHandler(create func(next http.Handler) http.Handler) {
+       app.preHandler = append(app.preHandler, create)
 }
 
 func (app *App) registerStatic() {
@@ -323,7 +352,7 @@ func (app *App) ListenAndServe() error {
 func (app *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        app.initOnce()
        app.Logger.Debug("serving request", "host", r.Host, "path", r.URL.Path)
-       app.ServeMux.ServeHTTP(w, r)
+       app.handler.ServeHTTP(w, r)
 }
 
 func (app *App) Shutdown(ctx context.Context) error {
index 53afd1335db6758eade722ca2c4d8423f60501a9..0ddf6b9bd4c390ccade874a2bb91279b58ffe746 100644 (file)
@@ -32,9 +32,9 @@ func isNotEligible(caller string, ignorePackages []string) bool {
        })
 }
 
-// getCallStackButt gets the calling file, ignoring any file in an ignored
-// package.
-func getCallStackButt(ignorePackages []string) (string, error) {
+// getCallingFile gets the calling file, ignoring any file in an
+// ignored package.
+func getCallingFile(ignorePackages []string) (string, error) {
        const incr int = 2
        const max int = incr * 10
        for skip := 0; skip < max; skip += incr {
index 101c3f82b4456b10d9197cf30987c895b13c11e1..6f571e3ae884fb5ae2b0f77cf4d308c4ed609d52 100644 (file)
@@ -40,7 +40,7 @@ func (fopener *fileOpener) FileName() string {
 
 // Dir gets the directory of the calling file.
 func Dir(ignorePackages ...string) (string, error) {
-       caller, err := getCallStackButt(ignorePackages)
+       caller, err := getCallingFile(ignorePackages)
        if err != nil {
                return "", err
        } else {
@@ -56,11 +56,11 @@ func Abs(filename string, ignorePackages ...string) (string, error) {
        if path.IsAbs(filename) {
                return filename, nil
        } else {
-               caller, err := getCallStackButt(ignorePackages)
+               dir, err := Dir(ignorePackages...)
                if err != nil {
                        return "", err
                } else {
-                       return path.Join(path.Dir(caller), filename), nil
+                       return path.Join(dir, filename), nil
                }
        }
 }
@@ -72,15 +72,11 @@ func Abs(filename string, ignorePackages ...string) (string, error) {
 // If there's an error accessing the file, it will be returned when calling
 // Open() (Reader, error).
 func File(filename string, ignorePackages ...string) (FileOpener, error) {
-       opener := new(fileOpener)
-       if path.IsAbs(filename) {
-               opener.absPath = filename
-       } else {
-               if caller, err := getCallStackButt(ignorePackages); err == nil {
-                       opener.absPath = path.Join(path.Dir(caller), filename)
-               } else {
-                       return nil, err
-               }
+       abs, err := Abs(filename, ignorePackages...)
+       if err != nil {
+               return nil, err
        }
+       opener := new(fileOpener)
+       opener.absPath = abs
        return opener, nil
 }
diff --git a/include/localized.go b/include/localized.go
new file mode 100644 (file)
index 0000000..87abc20
--- /dev/null
@@ -0,0 +1,151 @@
+package include
+
+import (
+       "os"
+       "path/filepath"
+       "strings"
+
+       "git.earlybird.gay/today/localization"
+       "git.earlybird.gay/today/localization/po"
+       "golang.org/x/text/language"
+)
+
+const (
+       LANGUAGES_DIR_NAME = ".languages"
+       LANGUAGE_FILE_EXT  = ".po"
+)
+
+var (
+       DefaultLanguage = language.English
+)
+
+type LocalizationInfo struct {
+       Languages       []language.Tag
+       Translations    map[language.Tag]po.Entries
+       DefaultLanguage language.Tag
+}
+
+// LocalizedFiles returns: a map of languages to file openers, an extra set of
+// information useful for acting on that map, or an error if one occurs.
+// This uses a path to the "nearest" .languages directory to the caller of
+// LocalizedFiles (usually a page or part, in the context of Today), to load
+// portable object files and manipulate filepaths.
+// Nearest in this context means "either in or above this directory, and the
+// fewest segments away", so for this file structure:
+//
+//     foo/
+//     |_ .languages/
+//     |_ bar/
+//     | |_ .languages/
+//     | |_ file.go
+//     |_ go.mod
+//
+// /foo/bar/.languages is "closer" to file.go than /foo/.languages.
+//
+// Returns an empty string if there isn't a .languages directory anywhere in the
+// valid tree (cannot traverse below a directory with a go.mod).
+func LocalizedFiles(filename string, ignorePackages ...string) (map[language.Tag]Opener, *LocalizationInfo, error) {
+       callerFile, err := getCallingFile(ignorePackages)
+       if err != nil {
+               return nil, nil, err
+       }
+       // Iterate down through the project.
+       // Stop when a .languages directory is found, or a go.mod file is found,
+       // whichever comes first.
+       dir := filepath.Dir(callerFile)
+       var baseDir, languagesDir string
+findLanguages:
+       for {
+               entries, err := os.ReadDir(dir)
+               if err != nil {
+                       return nil, nil, err
+               }
+               for _, entry := range entries {
+                       if entry.IsDir() && entry.Name() == LANGUAGES_DIR_NAME {
+                               baseDir = dir
+                               languagesDir = filepath.Join(dir, entry.Name())
+                               break findLanguages
+                       } else if entry.Name() == "go.mod" {
+                               file, err := File(filename, ignorePackages...)
+                               if err != nil {
+                                       return nil, nil, err
+                               }
+                               return map[language.Tag]Opener{
+                                       DefaultLanguage: file,
+                               }, nil, err
+                       }
+               }
+               dir = dir[:strings.LastIndexByte(dir, filepath.Separator)]
+       }
+       // localizedFiles := make(map[language.Tag]FileOpener)
+       // Get localization files and parse out the translations.
+       translations := make(map[language.Tag]po.Entries)
+       entries, err := os.ReadDir(languagesDir)
+       if err != nil {
+               return nil, nil, err
+       }
+       for _, entry := range entries {
+               ext := filepath.Ext(entry.Name())
+               lang := strings.TrimSuffix(entry.Name(), ext)
+               if filepath.Ext(entry.Name()) == LANGUAGE_FILE_EXT {
+                       tag, err := language.Parse(lang)
+                       if err != nil {
+                               return nil, nil, err
+                       }
+                       theseTranslations, err := po.File(filepath.Join(languagesDir, entry.Name()))
+                       if err != nil {
+                               return nil, nil, err
+                       }
+                       translations[tag] = theseTranslations
+               }
+       }
+       // Get: the absolute path to filename, the modifiable sections of the path,
+       // and the "default" language.
+       absPath, err := Abs(filename, ignorePackages...)
+       if err != nil {
+               return nil, nil, err
+       }
+       modifiablePath := strings.TrimPrefix(absPath, baseDir)
+       // Ignore the first argument from split, since it's always empty for an abs path
+       defaultLang, err := language.Parse(strings.Split(modifiablePath, string(filepath.Separator))[1])
+       if err != nil {
+               defaultLang = DefaultLanguage
+       }
+
+       // Iterate over every directory in baseDir.
+       // For each directory that has a base of a language tag, do path replacement
+       translatedPaths := make(map[language.Tag]string)
+       entries, err = os.ReadDir(baseDir)
+       if err != nil {
+               return nil, nil, err
+       }
+       for _, entry := range entries {
+               lang, err := language.Parse(entry.Name())
+               if err != nil {
+                       // Ignore directories that aren't tags
+                       continue
+               }
+               if trans, ok := translations[lang]; ok {
+                       translatedPath := localization.TranslatePath(defaultLang, lang, modifiablePath, trans)
+                       translatedPaths[lang] = filepath.Join(baseDir, translatedPath)
+               }
+       }
+
+       // Get openers for each translatedPath.
+       openers := make(map[language.Tag]Opener)
+       languages := make([]language.Tag, 0)
+       for tag, filename := range translatedPaths {
+               openers[tag], err = File(filename, ignorePackages...)
+               if err != nil {
+                       return nil, nil, err
+               }
+               languages = append(languages, tag)
+       }
+       meta := &LocalizationInfo{
+               Languages:       languages,
+               Translations:    translations,
+               DefaultLanguage: defaultLang,
+       }
+
+       return openers, meta, nil
+}
diff --git a/landing-page/.languages/ca.po b/landing-page/.languages/ca.po
new file mode 100644 (file)
index 0000000..f21bdb2
--- /dev/null
@@ -0,0 +1,19 @@
+# Today Landing Page (Catalan)
+# Early Nichols <me@earlybird.gay>, 2024
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"POT-Creation-Date: 2024-11-26\n"
+"PO-Revision-Date: 2024-11-26\n"
+"Last-Translator: Early Nichols <me@earlybird.gay>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=CHARSET\n"
+"Content-Transfer-Encoding: ENCODING\n"
+
+msgctxt "path"
+msgid "blog"
+msgstr "bloc"
+
+msgctxt "path"
+msgid "post.html"
+msgstr "correu.html"
diff --git a/landing-page/.languages/en.po b/landing-page/.languages/en.po
new file mode 100644 (file)
index 0000000..d8a53d3
--- /dev/null
@@ -0,0 +1,11 @@
+# Today Landing Page (English)
+# Early Nichols <me@earlybird.gay>, 2024
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"POT-Creation-Date: 2024-11-26\n"
+"PO-Revision-Date: 2024-11-26\n"
+"Last-Translator: Early Nichols <me@earlybird.gay>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=CHARSET\n"
+"Content-Transfer-Encoding: ENCODING\n"
diff --git a/landing-page/ca/bloc/correu.html b/landing-page/ca/bloc/correu.html
new file mode 100644 (file)
index 0000000..9b73126
--- /dev/null
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<head>
+    <title>Today Web Framework</title>
+</head>
+<body>
+    <header>
+        <h1>Today</h1>
+    </header>
+    <main>
+        <h1>Un correu del bloc</h1>
+    </main>
+    <footer>
+        <p>Copyright 2024 Early Nichols</p>
+    </footer>
+</body>
\ No newline at end of file
diff --git a/landing-page/ca/bloc/index.html b/landing-page/ca/bloc/index.html
new file mode 100644 (file)
index 0000000..8752380
--- /dev/null
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<head>
+    <title>Today Web Framework</title>
+</head>
+<body>
+    <header>
+        <h1>Today</h1>
+    </header>
+    <main>
+        <h1>Bloc de desenvolupador</h1>
+    </main>
+    <footer>
+        <p>Copyright 2024 Early Nichols</p>
+    </footer>
+</body>
\ No newline at end of file
diff --git a/landing-page/ca/index.html b/landing-page/ca/index.html
new file mode 100644 (file)
index 0000000..166cae4
--- /dev/null
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<head>
+    <title>Today Web Framework</title>
+</head>
+<body>
+    <header>
+        <h1>Today</h1>
+    </header>
+    <main>
+        <p>Aquest Ã©s la pagina principal per Today, un marc del lloc web.</p>
+    </main>
+    <footer>
+        <p>Copyright 2024 Early Nichols</p>
+    </footer>
+</body>
\ No newline at end of file
diff --git a/landing-page/en/blog/index.html b/landing-page/en/blog/index.html
new file mode 100644 (file)
index 0000000..566b558
--- /dev/null
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<head>
+    <title>Today Web Framework</title>
+</head>
+<body>
+    <header>
+        <h1>Today</h1>
+    </header>
+    <main>
+        <h1>Today Devblog</h1>
+    </main>
+    <footer>
+        <p>Copyright 2024 Early Nichols</p>
+    </footer>
+</body>
\ No newline at end of file
diff --git a/landing-page/en/blog/post.go b/landing-page/en/blog/post.go
new file mode 100644 (file)
index 0000000..999e2de
--- /dev/null
@@ -0,0 +1,5 @@
+package blog
+
+import "git.earlybird.gay/today/web/page"
+
+var Post = page.New("Post", "post.html")
diff --git a/landing-page/en/blog/post.html b/landing-page/en/blog/post.html
new file mode 100644 (file)
index 0000000..e04f15d
--- /dev/null
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<head>
+    <title>Today Web Framework</title>
+</head>
+<body>
+    <header>
+        <h1>Today</h1>
+    </header>
+    <main>
+        <p>A blog post</p>
+    </main>
+    <footer>
+        <p>Copyright 2024 Early Nichols</p>
+    </footer>
+</body>
\ No newline at end of file
diff --git a/landing-page/en/index.html b/landing-page/en/index.html
new file mode 100644 (file)
index 0000000..49094eb
--- /dev/null
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<head>
+    <title>Today Web Framework</title>
+</head>
+<body>
+    <header>
+        <h1>Today</h1>
+    </header>
+    <main>
+        <p>This is a landing page for the Today web framework.</p>
+    </main>
+    <footer>
+        <p>Copyright 2024 Early Nichols</p>
+    </footer>
+</body>
\ No newline at end of file
diff --git a/landing-page/main.go b/landing-page/main.go
new file mode 100644 (file)
index 0000000..32a6488
--- /dev/null
@@ -0,0 +1,24 @@
+package main
+
+import (
+       "fmt"
+
+       tapp "git.earlybird.gay/today/app"
+       "git.earlybird.gay/today/landing-page/en/blog"
+       "git.earlybird.gay/today/localization"
+       "golang.org/x/text/language"
+)
+
+func main() {
+       app := tapp.New()
+       tapp.GetEnv().Apply(app)
+
+       app.PreHandler(localization.SetLocaleFromPath("lang", language.English))
+
+       app.Static("/", ".")
+       app.Handle("GET /{lang}/blog/{post_id}", blog.Post)
+       err := app.ListenAndServe()
+       if err != nil {
+               fmt.Println(err)
+       }
+}
diff --git a/localization/http.go b/localization/http.go
new file mode 100644 (file)
index 0000000..83f4860
--- /dev/null
@@ -0,0 +1,83 @@
+package localization
+
+import (
+       "context"
+       "net/http"
+       "path/filepath"
+       "strings"
+
+       "git.earlybird.gay/today/localization/po"
+       "golang.org/x/text/language"
+)
+
+type ContextKey string
+
+const (
+       Locale = ContextKey("locale")
+)
+
+// SetLocaleFromHeader sets r.Context.Value(today.Locale) to the language in
+// the "Accept-Language" header, if applicable, or defaultLang.
+func SetLocaleFromHeader(defaultLang language.Tag) func(next http.Handler) http.Handler {
+       return func(next http.Handler) http.Handler {
+               return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+                       use := func(lang language.Tag) {
+                               next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), Locale, lang)))
+                       }
+                       lang := r.Header.Get("Accept-Language")
+                       if lang == "" {
+                               use(defaultLang)
+                               return
+                       }
+                       tag := language.Make(lang)
+                       use(tag)
+               })
+       }
+}
+
+// SetLocaleFromHeader sets r.Context.Value(today.Locale) to the language in
+// the path param named by "param", or to defaultLang.
+// To avoid confusion, consider redirecting the user if they input a param that
+// isn't valid.
+func SetLocaleFromPath(param string, defaultLang language.Tag) func(next http.Handler) http.Handler {
+       return func(next http.Handler) http.Handler {
+               return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+                       use := func(lang language.Tag) {
+                               next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), Locale, lang)))
+                       }
+                       lang := r.PathValue(param)
+                       if lang == "" {
+                               use(defaultLang)
+                               return
+                       }
+                       tag := language.Make(lang)
+                       use(tag)
+               })
+       }
+}
+
+// TranslatePath translates "path" between two languages.
+// This replacement will do the following for each segment of path:
+//   - If the segment is the tag "from", it becomes "to".
+//   - If the segment has a translation with msgctxt "path", it becomes the
+//     msgstr for that translation.
+//   - Otherwise, the segment is unchanged.
+func TranslatePath(from, to language.Tag, path string, translations po.Entries) string {
+       segments := strings.Split(path, string(filepath.Separator))
+       leadingSeparator := segments[0] == ""
+       if leadingSeparator {
+               segments = segments[1:]
+       }
+       for i, segment := range segments {
+               if segment == from.String() {
+                       segments[i] = to.String()
+               } else if translated := translations.Get("path", segment); translated != nil {
+                       segments[i] = translated.MsgStr
+               }
+       }
+       translated := strings.Join(segments, string(filepath.Separator))
+       if leadingSeparator {
+               translated = string(filepath.Separator) + translated
+       }
+       return translated
+}
diff --git a/localization/languages.go b/localization/languages.go
new file mode 100644 (file)
index 0000000..beee584
--- /dev/null
@@ -0,0 +1,5 @@
+package localization
+
+type LanguagesMeta struct {
+       Default string `json:"default"`
+}
diff --git a/localization/po/po.go b/localization/po/po.go
new file mode 100644 (file)
index 0000000..703d5c1
--- /dev/null
@@ -0,0 +1,143 @@
+package po
+
+import (
+       "bufio"
+       "fmt"
+       "io"
+       "os"
+       "slices"
+       "strings"
+)
+
+type Entry struct {
+       TranslatorComments string
+       ExtractedComments  string
+       References         []string
+       Flags              []string
+
+       MsgCtxt string
+       MsgId   string
+       MsgStr  string
+}
+type Entries []*Entry
+
+func (e *Entry) String() string {
+       return fmt.Sprintf("%s -> %s", e.MsgId, e.MsgStr)
+}
+
+type buildingEntry struct {
+       *Entry
+       buildTranslatorComments []string
+       buildExtractedComments  []string
+}
+
+// Get returns an entry from entries, matching the given msgctxt and msgid.
+// An empty string may be provided as msgctxt; the first matching msgid will
+// be returned. In formats that use msgctxt, this may result in undesirable
+// behavior; specify msgctxt if you know it.
+func (entries Entries) Get(msgctxt, msgid string) *Entry {
+       idx := slices.IndexFunc(entries, func(e *Entry) bool {
+               return (msgctxt == "" || msgctxt == e.MsgCtxt) && msgid == e.MsgId
+       })
+       if idx == -1 {
+               return nil
+       } else {
+               return entries[idx]
+       }
+}
+
+const (
+       translatorComment = "#"
+       extractedComment  = "#."
+       reference         = "#:"
+       flag              = "#,"
+)
+
+// Decode reads a .po file from r and creates a *PortableObject with the
+// resulting translation.
+func Decode(r io.Reader) (Entries, error) {
+       translations := make(Entries, 0)
+       var currentEntry *buildingEntry
+       finishEntry := func() {
+               if currentEntry.MsgId == "" {
+                       currentEntry = nil
+                       return
+               }
+               currentEntry.Entry.TranslatorComments = strings.Join(currentEntry.buildTranslatorComments, " ")
+               currentEntry.Entry.ExtractedComments = strings.Join(currentEntry.buildExtractedComments, " ")
+               translations = append(translations, currentEntry.Entry)
+               currentEntry = nil
+       }
+
+       file := bufio.NewScanner(r)
+       for file.Scan() {
+               line := file.Text()
+               // Whitespace
+               // If there's a line that is only whitespace, we're getting ready for
+               // a new entry. Add the current one to the PortableObject, then move
+               // on.
+               if strings.TrimSpace(line) == "" {
+                       if currentEntry != nil {
+                               finishEntry()
+                       }
+                       continue
+               }
+               // If there isn't whitespace, and there's no current entry, start a
+               // new one.
+               if currentEntry == nil {
+                       currentEntry = new(buildingEntry)
+                       currentEntry.Entry = new(Entry)
+               }
+               // Check for comments, in order of specificity.
+               // Extracted comments #.
+               if comment, ok := strings.CutPrefix(line, extractedComment); ok {
+                       comment = strings.TrimSpace(comment)
+                       currentEntry.buildExtractedComments = append(currentEntry.buildExtractedComments, comment)
+                       continue
+               }
+               // References #:
+               if comment, ok := strings.CutPrefix(line, reference); ok {
+                       references := strings.Split(strings.TrimSpace(comment), " ")
+                       currentEntry.References = append(currentEntry.References, references...)
+                       continue
+               }
+               // Flags #,
+               if comment, ok := strings.CutPrefix(line, reference); ok {
+                       flags := strings.Split(strings.TrimSpace(comment), " ")
+                       currentEntry.Flags = append(currentEntry.Flags, flags...)
+                       continue
+               }
+               // Translator comments #
+               if comment, ok := strings.CutPrefix(line, extractedComment); ok {
+                       comment = strings.TrimSpace(comment)
+                       currentEntry.buildTranslatorComments = append(currentEntry.buildTranslatorComments, comment)
+                       continue
+               }
+               // Message context
+               if msgctxt, ok := strings.CutPrefix(line, "msgctxt"); ok {
+                       currentEntry.MsgCtxt = strings.Trim(msgctxt, "\"\t ")
+                       continue
+               }
+               // Message ID or untranslated string
+               if msgid, ok := strings.CutPrefix(line, "msgid"); ok {
+                       currentEntry.MsgId = strings.Trim(msgid, "\"\t ")
+                       continue
+               }
+               // Translated string
+               if msgstr, ok := strings.CutPrefix(line, "msgstr"); ok {
+                       currentEntry.MsgStr = strings.Trim(msgstr, "\"\t ")
+                       continue
+               }
+       }
+       finishEntry()
+       return translations, nil
+}
+
+// File calls Decode on a filename.
+func File(filename string) (Entries, error) {
+       r, err := os.Open(filename)
+       if err != nil {
+               return nil, err
+       }
+       return Decode(r)
+}
index 5149dbfd874aca6139c18b12b2d31395513e7b3c..700a03e8f51c561fa29454798710d8a7e2ec16c7 100644 (file)
@@ -11,11 +11,13 @@ import (
        "git.earlybird.gay/today/web/htmltree"
        "git.earlybird.gay/today/web/internal/html"
        "git.earlybird.gay/today/web/render"
+       "golang.org/x/text/language"
 )
 
 type Source interface {
        Name() string
-       Source() include.Opener
+       Languages() []language.Tag
+       Source(lang language.Tag) include.Opener
        Includes() []Source
        Error() error
 }
@@ -37,133 +39,139 @@ func mapSources(dst Sources, slice []Source) {
 }
 
 type Result struct {
+       Language           language.Tag
        TemplateRaw        string
        TemplateFuncs      template.FuncMap
        TemplateDataLoader render.Loader
 }
 
 func Compile(root TemplateSource, transform ...func(root *html.Node)) ([]Result, error) {
-       reader, err := root.Source().Open()
-       if err != nil {
-               return nil, err
-       }
-       document, err := html.Parse(reader)
-       if err != nil {
-               return nil, err
-       }
-
-       fullDependencies := Sources{root.Name(): root}
-       mapSources(fullDependencies, root.Includes())
-       computeRoot := &computeNode{
-               name:    root.Name(),
-               compute: root.OnLoad(),
-       }
-       // Insert an assignment to $compute so we can always raise scope, even
-       // in pipelines
-       document.InsertBefore(&html.Node{
-               Type: html.TextNode,
-               Data: "{{ $compute := .compute }}",
-       }, document.FirstChild)
-
-       // Replace template functions in the root before we add any subsources
-       // fmt.Println(root.TemplateFuncs())
-       htmltree.Walk(document, func(n *html.Node) (bool, error) {
-               replaceTemplateFuncs(root, n)
-               return false, nil
-       })
-
-       // Insert component sources into document
-       for name, subSource := range fullDependencies {
-               // Easiest way to tell right now is what isn't a template source,
-               // but this bears re-evaluating later
-               if _, ok := subSource.(TemplateSource); ok {
-                       continue
+       results := make([]Result, len(root.Languages()))
+       for i, lang := range root.Languages() {
+               reader, err := root.Source(lang).Open()
+               if err != nil {
+                       return nil, err
                }
-               err := insertComponentSource(subSource, document)
+               document, err := html.Parse(reader)
                if err != nil {
                        return nil, err
                }
-               delete(fullDependencies, name)
-       }
 
-       // gather global template funcs
-       // Start with the root's template, then add template funcs for all subsource
-       // with a namespace
-       templateFuncs := make(template.FuncMap)
-       for fname, f := range root.TemplateFuncs() {
-               templateFuncs[snakeCase(root.Name())+"_"+fname] = f
-       }
+               fullDependencies := Sources{root.Name(): root}
+               mapSources(fullDependencies, root.Includes())
+               computeRoot := &computeNode{
+                       name:    root.Name(),
+                       compute: root.OnLoad(),
+               }
+               // Insert an assignment to $compute so we can always raise scope, even
+               // in pipelines
+               document.InsertBefore(&html.Node{
+                       Type: html.TextNode,
+                       Data: "{{ $compute := .compute }}",
+               }, document.FirstChild)
+
+               // Replace template functions in the root before we add any subsources
+               // fmt.Println(root.TemplateFuncs())
+               htmltree.Walk(document, func(n *html.Node) (bool, error) {
+                       replaceTemplateFuncs(root, n)
+                       return false, nil
+               })
+
+               // Insert component sources into document
+               for name, subSource := range fullDependencies {
+                       // Easiest way to tell right now is what isn't a template source,
+                       // but this bears re-evaluating later
+                       if _, ok := subSource.(TemplateSource); ok {
+                               continue
+                       }
+                       err := insertComponentSource(lang, subSource, document)
+                       if err != nil {
+                               return nil, err
+                       }
+                       delete(fullDependencies, name)
+               }
 
-       var process func(n *html.Node, c *computeNode) error
-       process = func(n *html.Node, c *computeNode) error {
-               childComputeNode := c
-               siblingComputeNode := c
-               if n.Type == html.ElementNode {
-                       if subSource, ok := fullDependencies[n.Data]; ok {
-                               if templateSource, ok := subSource.(TemplateSource); ok {
-                                       // Template sources (parts) are inserted inline
-                                       // Add template funcs
-                                       for fname, f := range templateSource.TemplateFuncs() {
-                                               templateFuncs[snakeCase(n.Data)+"_"+fname] = f
-                                       }
-                                       // Parse HTML fragment and replace the content of the node with it
-                                       computeSubSource, err := insertTemplateSource(templateSource, n)
-                                       if err != nil {
-                                               return err
+               // gather global template funcs
+               // Start with the root's template, then add template funcs for all subsource
+               // with a namespace
+               templateFuncs := make(template.FuncMap)
+               for fname, f := range root.TemplateFuncs() {
+                       templateFuncs[snakeCase(root.Name())+"_"+fname] = f
+               }
+
+               var process func(n *html.Node, c *computeNode) error
+               process = func(n *html.Node, c *computeNode) error {
+                       childComputeNode := c
+                       siblingComputeNode := c
+                       if n.Type == html.ElementNode {
+                               if subSource, ok := fullDependencies[n.Data]; ok {
+                                       if templateSource, ok := subSource.(TemplateSource); ok {
+                                               // Template sources (parts) are inserted inline
+                                               // Add template funcs
+                                               for fname, f := range templateSource.TemplateFuncs() {
+                                                       templateFuncs[snakeCase(n.Data)+"_"+fname] = f
+                                               }
+                                               // Parse HTML fragment and replace the content of the node with it
+                                               computeSubSource, err := insertTemplateSource(lang, templateSource, n)
+                                               if err != nil {
+                                                       return err
+                                               }
+                                               // Add compute node for subsource
+                                               c.children = append(c.children, computeSubSource)
+                                               childComputeNode = computeSubSource
                                        }
-                                       // Add compute node for subsource
-                                       c.children = append(c.children, computeSubSource)
-                                       childComputeNode = computeSubSource
                                }
                        }
+                       var procErr error
+                       if n.FirstChild != nil {
+                               procErr = errors.Join(procErr, process(n.FirstChild, childComputeNode))
+                       }
+                       if n.NextSibling != nil {
+                               procErr = errors.Join(procErr, process(n.NextSibling, siblingComputeNode))
+                       }
+                       return procErr
                }
-               var procErr error
-               if n.FirstChild != nil {
-                       procErr = errors.Join(procErr, process(n.FirstChild, childComputeNode))
-               }
-               if n.NextSibling != nil {
-                       procErr = errors.Join(procErr, process(n.NextSibling, siblingComputeNode))
+               err = process(document, computeRoot)
+               if err != nil {
+                       return nil, err
                }
-               return procErr
-       }
-       err = process(document, computeRoot)
-       if err != nil {
-               return nil, err
-       }
 
-       // POSTPROCESSING
+               // POSTPROCESSING
 
-       // Assign .SetDot to $setDot
-       document.InsertBefore(&html.Node{
-               Type: html.TextNode,
-               Data: `{{ $setDot := .SetDot }}{{ with .Data }}`,
-       }, document.FirstChild)
-       // Add end to end
-       document.InsertBefore(&html.Node{
-               Type: html.TextNode,
-               Data: "{{ end -}}",
-       }, nil)
+               // Assign .SetDot to $setDot
+               document.InsertBefore(&html.Node{
+                       Type: html.TextNode,
+                       Data: `{{ $setDot := .SetDot }}{{ with .Data }}`,
+               }, document.FirstChild)
+               // Add end to end
+               document.InsertBefore(&html.Node{
+                       Type: html.TextNode,
+                       Data: "{{ end -}}",
+               }, nil)
 
-       // Split up templates in text nodes
-       splitTemplateNodes(document)
+               // Split up templates in text nodes
+               splitTemplateNodes(document)
 
-       // Traverse the document and add $setDot on entry and exit from pipelines
-       addSetDots(document)
+               // Traverse the document and add $setDot on entry and exit from pipelines
+               addSetDots(document)
 
-       for _, tf := range transform {
-               tf(document)
-       }
+               for _, tf := range transform {
+                       tf(document)
+               }
 
-       raw, err := renderDocument(document)
-       if err != nil {
-               return nil, err
-       }
-       result := Result{
-               TemplateRaw:        raw,
-               TemplateFuncs:      templateFuncs,
-               TemplateDataLoader: computeRoot,
+               raw, err := renderDocument(document)
+               if err != nil {
+                       return nil, err
+               }
+               result := Result{
+                       Language:           lang,
+                       TemplateRaw:        raw,
+                       TemplateFuncs:      templateFuncs,
+                       TemplateDataLoader: computeRoot,
+               }
+               results[i] = result
        }
-       return []Result{result}, nil
+       return results, nil
 }
 
 func renderDocument(document *html.Node) (string, error) {
index 7fd726ccf905af4643be890ee76193b62dc48c9d..38912756c358902ce360e6b2baa90fa912affe28 100644 (file)
@@ -6,6 +6,7 @@ import (
        "git.earlybird.gay/today/web/htmltree"
        "git.earlybird.gay/today/web/internal/html"
        "git.earlybird.gay/today/web/internal/html/atom"
+       "golang.org/x/text/language"
 )
 
 var ErrBadComponentFormat = errors.New("web components must either be a script or a template and script")
@@ -13,13 +14,13 @@ var ErrBadComponentFormat = errors.New("web components must either be a script o
 // insertComponentSource inserts a web component subSource into a document.
 // Unlike insertTemplateSource, this expects the root document and not the
 // context where the web component is being included.
-func insertComponentSource(subSource Source, document *html.Node) error {
+func insertComponentSource(lang language.Tag, subSource Source, document *html.Node) error {
        // ===== SUBSOURCE PROCESSING =====
        // Parse the subSource in the context of a node named subSource.Name().
        // subSource should be:
        // <template> and <script>
        // <script>
-       reader, err := subSource.Source().Open()
+       reader, err := subSource.Source(lang).Open()
        if err != nil {
                return err
        }
index 369240636bb48339eedd8507b9e1df866e1b14eb..733cafe40dee826276dd5313437134f6f5cdecde 100644 (file)
@@ -10,6 +10,7 @@ import (
 
        "git.earlybird.gay/today/web/htmltree"
        "git.earlybird.gay/today/web/internal/html"
+       "golang.org/x/text/language"
 )
 
 const (
@@ -326,7 +327,7 @@ func removeDataAttrs(n *html.Node) map[string]string {
        return dataAttrs
 }
 
-func insertTemplateSource(subSource TemplateSource, context *html.Node) (*computeNode, error) {
+func insertTemplateSource(lang language.Tag, subSource TemplateSource, context *html.Node) (*computeNode, error) {
        computedName := snakeCase(subSource.Name())
        // ===== CONTEXT PREPROCESSING =====
        // Raise any fields pre-existing in the context to .parent.field.
@@ -351,7 +352,7 @@ func insertTemplateSource(subSource TemplateSource, context *html.Node) (*comput
        // ===== SUBSOURCE PROCESSING =====
        // Parse the subSource in the context of context.
        // Require that subSource be contained in a template.
-       reader, err := subSource.Source().Open()
+       reader, err := subSource.Source(lang).Open()
        if err != nil {
                return nil, err
        }
index 2674eb2dc4cb841bba288b714b33027c4c1f1f84..c718511d23724df10a3719485502c705f9bd2d15 100644 (file)
@@ -5,28 +5,34 @@ import (
        "context"
        "errors"
        "html/template"
+       "net/http"
        "path/filepath"
-       "strings"
 
        "git.earlybird.gay/today/include"
+       "git.earlybird.gay/today/localization"
+       "git.earlybird.gay/today/localization/po"
        "git.earlybird.gay/today/web/htmltree"
        "git.earlybird.gay/today/web/internal/compile"
        "git.earlybird.gay/today/web/part"
        "git.earlybird.gay/today/web/render"
+       "golang.org/x/text/language"
 )
 
 type Page struct {
-       name   string
-       source include.Opener
-       raw    string
+       name            string
+       languages       []language.Tag
+       translations    map[language.Tag]po.Entries
+       defaultLanguage language.Tag
+       sources         map[language.Tag]include.Opener
 
        includes []compile.Source
        onLoad   render.OnLoadFunc
        indent   string
 
-       template      *template.Template
        templateFuncs template.FuncMap
-       templateLoad  render.Loader
+
+       raw              string
+       chooseRenderable func(r *http.Request) *renderable
 
        err error
 }
@@ -41,7 +47,8 @@ func Name(name string) Config {
 
 func Source(source string) Config {
        return func(p *Page) {
-               p.source, p.err = include.File(source, "git.earlybird.gay/today/web/part")
+               p.languages = []language.Tag{language.English}
+               p.sources[language.English], p.err = include.File(source, "git.earlybird.gay/today/web/part")
        }
 }
 
@@ -78,10 +85,15 @@ func New(name string, source string, optional ...Config) *Page {
        p := new(Page)
        // Assign basic parameters
        p.name = name
-       p.source, p.err = include.File(source, "git.earlybird.gay/today/web/page")
-       if p.err != nil {
+       sources, localeMeta, err := include.LocalizedFiles(source, "git.earlybird.gay/today/web/page")
+       if err != nil {
+               p.err = err
                return p
        }
+       p.languages = localeMeta.Languages
+       p.translations = localeMeta.Translations
+       p.defaultLanguage = localeMeta.DefaultLanguage
+       p.sources = sources
        p.onLoad = func(ctx context.Context, d render.Data) error {
                return nil
        }
@@ -101,17 +113,37 @@ func New(name string, source string, optional ...Config) *Page {
                p.err = err
                return p
        }
-       result := results[0]
-       p.raw = result.TemplateRaw
-       p.templateLoad = result.TemplateDataLoader
 
-       // templatize
-       p.template, err = template.New(p.name).
-               Funcs(result.TemplateFuncs).
-               Parse(result.TemplateRaw)
-       if err != nil {
-               p.err = err
-               return p
+       renderables := make(map[language.Tag]*renderable)
+       for _, result := range results {
+               // templatize
+               tmpl, err := template.New(p.name).
+                       Funcs(result.TemplateFuncs).
+                       Parse(result.TemplateRaw)
+               if err != nil {
+                       p.err = err
+                       return p
+               }
+               renderables[result.Language] = &renderable{
+                       template:     tmpl,
+                       templateLoad: result.TemplateDataLoader,
+               }
+       }
+       // result := results[0]
+       // p.raw = result.TemplateRaw
+       // p.templateLoad = result.TemplateDataLoader
+
+       // selector := language.NewMatcher(localeMeta.Languages)
+       p.chooseRenderable = func(r *http.Request) *renderable {
+               lang, ok := r.Context().Value(localization.Locale).(language.Tag)
+               if !ok {
+                       return renderables[localeMeta.DefaultLanguage]
+               }
+               if chosen, ok := renderables[lang]; ok {
+                       return chosen
+               } else {
+                       return renderables[localeMeta.DefaultLanguage]
+               }
        }
        return p
 }
@@ -119,16 +151,68 @@ func New(name string, source string, optional ...Config) *Page {
 // Static returns a new Page that is just the passed source file, with no
 // additional options/parts/etc.
 func Static(source string) *Page {
-       filename := filepath.Base(source)
-       name := strings.TrimSuffix(filename, filepath.Ext(filename))
-       return New(name, source)
+       p := new(Page)
+       // Assign basic parameters
+       p.name = filepath.Base(source)
+       opener, err := include.File(source, "git.earlybird.gay/today/web/page")
+       if err != nil {
+               p.err = err
+               return p
+       }
+       p.languages = []language.Tag{include.DefaultLanguage}
+       p.translations = make(map[language.Tag]po.Entries)
+       p.defaultLanguage = include.DefaultLanguage
+       p.sources = map[language.Tag]include.Opener{
+               include.DefaultLanguage: opener,
+       }
+       p.onLoad = func(ctx context.Context, d render.Data) error {
+               return nil
+       }
+       p.templateFuncs = make(template.FuncMap)
+       // parse
+       // compile from assembled sources
+       sourceTransform := htmltree.Minify()
+       if p.indent != "" {
+               sourceTransform = htmltree.Prettify(p.indent)
+       }
+       results, err := compile.Compile(p, sourceTransform)
+       if err != nil {
+               p.err = err
+               return p
+       }
+
+       renderables := make(map[language.Tag]*renderable)
+       for _, result := range results {
+               // templatize
+               tmpl, err := template.New(p.name).
+                       Funcs(result.TemplateFuncs).
+                       Parse(result.TemplateRaw)
+               if err != nil {
+                       p.err = err
+                       return p
+               }
+               renderables[result.Language] = &renderable{
+                       template:     tmpl,
+                       templateLoad: result.TemplateDataLoader,
+               }
+       }
+       // result := results[0]
+       // p.raw = result.TemplateRaw
+       // p.templateLoad = result.TemplateDataLoader
+
+       // selector := language.NewMatcher(localeMeta.Languages)
+       p.chooseRenderable = func(r *http.Request) *renderable {
+               return renderables[include.DefaultLanguage]
+       }
+       return p
 }
 
 // With returns a shallow copy of p with all of optional applied to it.
 func (p *Page) With(optional ...Config) *Page {
        q := &Page{
-               name:   p.name,
-               source: p.source,
+               name:      p.name,
+               languages: p.languages,
+               sources:   p.sources,
 
                includes:      p.includes,
                onLoad:        p.onLoad,
@@ -149,16 +233,30 @@ func (p *Page) Name() string {
        return p.name
 }
 
-func (p *Page) Source() include.Opener {
-       return p.source
+func (p *Page) Languages() []language.Tag {
+       return p.languages
+}
+
+func (p *Page) Translations() map[language.Tag]po.Entries {
+       return p.translations
+}
+
+func (p *Page) DefaultLanguage() language.Tag {
+       return p.defaultLanguage
+}
+
+func (p *Page) Source(lang language.Tag) include.Opener {
+       return p.sources[lang]
 }
 
 // FileDependencies returns a list of absolute paths to files that are used in
 // this page and its dependencies.
 func (p *Page) FileDependencies() []string {
        out := make([]string, 0)
-       if fopener, ok := p.Source().(include.FileOpener); ok {
-               out = append(out, fopener.FileName())
+       for _, lang := range p.languages {
+               if fopener, ok := p.Source(lang).(include.FileOpener); ok {
+                       out = append(out, fopener.FileName())
+               }
        }
        for _, dep := range p.includes {
                partDep, _ := dep.(*part.Part)
index 75f8bcaa76e6a1902a3e5e508ff6159996b44683..427983f01b204ffaba2e09c36cd20ae85e816e6b 100644 (file)
@@ -2,6 +2,7 @@
 package page
 
 import (
+       "html/template"
        "net/http"
 
        "git.earlybird.gay/today/web/render"
@@ -13,8 +14,14 @@ type RootData struct {
        Data render.Data
 }
 
+type renderable struct {
+       template     *template.Template
+       templateLoad render.Loader
+}
+
 func (p *Page) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-       data, setDot, err := p.templateLoad.Compute(r)
+       toRender := p.chooseRenderable(r)
+       data, setDot, err := toRender.templateLoad.Compute(r)
        if err != nil {
                panic(err)
        }
@@ -23,7 +30,7 @@ func (p *Page) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
                Data: data,
        }
-       err = p.template.Execute(w, root)
+       err = toRender.template.Execute(w, root)
        if err != nil {
                panic(err)
        }
index ac071283454b4c434063b8a45cd6f8a24bba7a77..5b6733d04267c8ab039d6c48d1fc07ebf67256c3 100644 (file)
@@ -9,11 +9,13 @@ import (
        "git.earlybird.gay/today/include"
        "git.earlybird.gay/today/web/internal/compile"
        "git.earlybird.gay/today/web/render"
+       "golang.org/x/text/language"
 )
 
 type Part struct {
-       name   string
-       source include.Opener
+       name      string
+       languages []language.Tag
+       sources   map[language.Tag]include.Opener
 
        noTag         bool
        includes      []compile.Source
@@ -33,7 +35,8 @@ func Name(name string) Config {
 
 func Source(source string) Config {
        return func(p *Part) {
-               p.source, p.err = include.File(source, "git.earlybird.gay/today/web/part")
+               p.languages = []language.Tag{language.English}
+               p.sources[language.English], p.err = include.File(source, "git.earlybird.gay/today/web/part")
        }
 }
 
@@ -74,7 +77,9 @@ func New(name string, source string, optional ...Config) *Part {
        p := new(Part)
        // Assign basic parameters
        p.name = name
-       p.source, p.err = include.File(source, "git.earlybird.gay/today/web/part")
+       p.languages = []language.Tag{language.English}
+       p.sources = make(map[language.Tag]include.Opener)
+       p.sources[language.English], p.err = include.File(source, "git.earlybird.gay/today/web/part")
        if p.err != nil {
                return p
        }
@@ -95,8 +100,9 @@ func New(name string, source string, optional ...Config) *Part {
 // With returns a shallow copy of p with all of optional applied to it.
 func (p *Part) With(optional ...Config) *Part {
        q := &Part{
-               name:   p.name,
-               source: p.source,
+               name:      p.name,
+               languages: p.languages,
+               sources:   p.sources,
 
                includes:      p.includes,
                onLoad:        p.onLoad,
@@ -116,16 +122,22 @@ func (p *Part) Name() string {
        return p.name
 }
 
-func (p *Part) Source() include.Opener {
-       return p.source
+func (p *Part) Languages() []language.Tag {
+       return p.languages
+}
+
+func (p *Part) Source(lang language.Tag) include.Opener {
+       return p.sources[lang]
 }
 
 // FileDependencies returns a list of absolute paths to files that are used in
 // this part and its dependencies.
 func (p *Part) FileDependencies() []string {
        out := make([]string, 0)
-       if fopener, ok := p.Source().(include.FileOpener); ok {
-               out = append(out, fopener.FileName())
+       for _, lang := range p.languages {
+               if fopener, ok := p.Source(lang).(include.FileOpener); ok {
+                       out = append(out, fopener.FileName())
+               }
        }
        for _, dep := range p.includes {
                partDep, _ := dep.(*Part)