"sync/atomic"
"git.earlybird.gay/today/include"
+ "git.earlybird.gay/today/localization"
"git.earlybird.gay/today/web/page"
)
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
app := new(App)
app.shutdown = make(chan struct{})
app.stop = make(chan struct{})
+ app.handler = &app.ServeMux
return app
}
return err
}
if stat.IsDir() {
- // Recurse to subdirectories
entries, err := os.ReadDir(fpath)
if err != nil {
return err
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() {
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 {
})
}
-// 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 {
// 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 {
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
}
}
}
// 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
}
--- /dev/null
+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
+}
--- /dev/null
+# 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"
--- /dev/null
+# 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"
--- /dev/null
+<!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
--- /dev/null
+<!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
--- /dev/null
+<!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
--- /dev/null
+<!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
--- /dev/null
+package blog
+
+import "git.earlybird.gay/today/web/page"
+
+var Post = page.New("Post", "post.html")
--- /dev/null
+<!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
--- /dev/null
+<!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
--- /dev/null
+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)
+ }
+}
--- /dev/null
+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
+}
--- /dev/null
+package localization
+
+type LanguagesMeta struct {
+ Default string `json:"default"`
+}
--- /dev/null
+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)
+}
"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
}
}
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) {
"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")
// 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
}
"git.earlybird.gay/today/web/htmltree"
"git.earlybird.gay/today/web/internal/html"
+ "golang.org/x/text/language"
)
const (
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.
// ===== 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
}
"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
}
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")
}
}
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
}
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
}
// 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,
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)
package page
import (
+ "html/template"
"net/http"
"git.earlybird.gay/today/web/render"
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)
}
Data: data,
}
- err = p.template.Execute(w, root)
+ err = toRender.template.Execute(w, root)
if err != nil {
panic(err)
}
"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
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")
}
}
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
}
// 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,
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)