From e8e4f80c7b81a173875abd2bef7048335c4ae9f8 Mon Sep 17 00:00:00 2001 From: early Date: Mon, 25 Nov 2024 04:59:15 -0700 Subject: [PATCH] Lay out localization for pages --- app/app.go | 35 ++++- include/callstack.go | 6 +- include/include.go | 20 ++- include/localized.go | 151 +++++++++++++++++++++ landing-page/.languages/ca.po | 19 +++ landing-page/.languages/en.po | 11 ++ landing-page/ca/bloc/correu.html | 15 +++ landing-page/ca/bloc/index.html | 15 +++ landing-page/ca/index.html | 15 +++ landing-page/en/blog/index.html | 15 +++ landing-page/en/blog/post.go | 5 + landing-page/en/blog/post.html | 15 +++ landing-page/en/index.html | 15 +++ landing-page/main.go | 24 ++++ localization/http.go | 83 ++++++++++++ localization/languages.go | 5 + localization/po/po.go | 143 ++++++++++++++++++++ web/internal/compile/compile.go | 216 ++++++++++++++++-------------- web/internal/compile/component.go | 5 +- web/internal/compile/template.go | 5 +- web/page/page.go | 154 +++++++++++++++++---- web/page/serve.go | 11 +- web/part/part.go | 32 +++-- 23 files changed, 849 insertions(+), 166 deletions(-) create mode 100644 include/localized.go create mode 100644 landing-page/.languages/ca.po create mode 100644 landing-page/.languages/en.po create mode 100644 landing-page/ca/bloc/correu.html create mode 100644 landing-page/ca/bloc/index.html create mode 100644 landing-page/ca/index.html create mode 100644 landing-page/en/blog/index.html create mode 100644 landing-page/en/blog/post.go create mode 100644 landing-page/en/blog/post.html create mode 100644 landing-page/en/index.html create mode 100644 landing-page/main.go create mode 100644 localization/http.go create mode 100644 localization/languages.go create mode 100644 localization/po/po.go diff --git a/app/app.go b/app/app.go index 8d82812..eb6ab84 100644 --- a/app/app.go +++ b/app/app.go @@ -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 { diff --git a/include/callstack.go b/include/callstack.go index 53afd13..0ddf6b9 100644 --- a/include/callstack.go +++ b/include/callstack.go @@ -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 { diff --git a/include/include.go b/include/include.go index 101c3f8..6f571e3 100644 --- a/include/include.go +++ b/include/include.go @@ -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 index 0000000..87abc20 --- /dev/null +++ b/include/localized.go @@ -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 index 0000000..f21bdb2 --- /dev/null +++ b/landing-page/.languages/ca.po @@ -0,0 +1,19 @@ +# Today Landing Page (Catalan) +# Early Nichols , 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 \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 index 0000000..d8a53d3 --- /dev/null +++ b/landing-page/.languages/en.po @@ -0,0 +1,11 @@ +# Today Landing Page (English) +# Early Nichols , 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 \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 index 0000000..9b73126 --- /dev/null +++ b/landing-page/ca/bloc/correu.html @@ -0,0 +1,15 @@ + + + Today Web Framework + + +
+

Today

+
+
+

Un correu del bloc

+
+
+

Copyright 2024 Early Nichols

+
+ \ 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 index 0000000..8752380 --- /dev/null +++ b/landing-page/ca/bloc/index.html @@ -0,0 +1,15 @@ + + + Today Web Framework + + +
+

Today

+
+
+

Bloc de desenvolupador

+
+
+

Copyright 2024 Early Nichols

+
+ \ No newline at end of file diff --git a/landing-page/ca/index.html b/landing-page/ca/index.html new file mode 100644 index 0000000..166cae4 --- /dev/null +++ b/landing-page/ca/index.html @@ -0,0 +1,15 @@ + + + Today Web Framework + + +
+

Today

+
+
+

Aquest és la pagina principal per Today, un marc del lloc web.

+
+
+

Copyright 2024 Early Nichols

+
+ \ 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 index 0000000..566b558 --- /dev/null +++ b/landing-page/en/blog/index.html @@ -0,0 +1,15 @@ + + + Today Web Framework + + +
+

Today

+
+
+

Today Devblog

+
+
+

Copyright 2024 Early Nichols

+
+ \ 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 index 0000000..999e2de --- /dev/null +++ b/landing-page/en/blog/post.go @@ -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 index 0000000..e04f15d --- /dev/null +++ b/landing-page/en/blog/post.html @@ -0,0 +1,15 @@ + + + Today Web Framework + + +
+

Today

+
+
+

A blog post

+
+
+

Copyright 2024 Early Nichols

+
+ \ No newline at end of file diff --git a/landing-page/en/index.html b/landing-page/en/index.html new file mode 100644 index 0000000..49094eb --- /dev/null +++ b/landing-page/en/index.html @@ -0,0 +1,15 @@ + + + Today Web Framework + + +
+

Today

+
+
+

This is a landing page for the Today web framework.

+
+
+

Copyright 2024 Early Nichols

+
+ \ No newline at end of file diff --git a/landing-page/main.go b/landing-page/main.go new file mode 100644 index 0000000..32a6488 --- /dev/null +++ b/landing-page/main.go @@ -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 index 0000000..83f4860 --- /dev/null +++ b/localization/http.go @@ -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 index 0000000..beee584 --- /dev/null +++ b/localization/languages.go @@ -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 index 0000000..703d5c1 --- /dev/null +++ b/localization/po/po.go @@ -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) +} diff --git a/web/internal/compile/compile.go b/web/internal/compile/compile.go index 5149dbf..700a03e 100644 --- a/web/internal/compile/compile.go +++ b/web/internal/compile/compile.go @@ -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) { diff --git a/web/internal/compile/component.go b/web/internal/compile/component.go index 7fd726c..3891275 100644 --- a/web/internal/compile/component.go +++ b/web/internal/compile/component.go @@ -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: //