go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/templates/loaders.go (about)

     1  // Copyright 2015 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package templates
    16  
    17  import (
    18  	"context"
    19  	"errors"
    20  	"html/template"
    21  	"io"
    22  	"io/fs"
    23  	"path/filepath"
    24  	"strings"
    25  )
    26  
    27  // AssetsLoader returns Loader that loads templates from the given assets map.
    28  //
    29  // The directory with templates is expected to have special structure:
    30  //   - 'pages/' contain all top-level templates that will be loaded
    31  //   - 'includes/' contain templates that will be associated with every top-level template from 'pages/'.
    32  //   - 'widgets/' contain all standalone templates that will be loaded without templates from 'includes/'
    33  //
    34  // Only templates from 'pages/' and 'widgets/' are included in the output map.
    35  func AssetsLoader(assets map[string]string) Loader {
    36  	return func(c context.Context, funcMap template.FuncMap) (map[string]*template.Template, error) {
    37  		// Pick all includes.
    38  		includes := []string(nil)
    39  		for name, body := range assets {
    40  			if strings.HasPrefix(name, "includes/") {
    41  				includes = append(includes, body)
    42  			}
    43  		}
    44  
    45  		// Parse all top level templates and associate them with all includes.
    46  		toplevel := map[string]*template.Template{}
    47  		for name, body := range assets {
    48  			if strings.HasPrefix(name, "pages/") {
    49  				t := template.New(name).Funcs(funcMap)
    50  				// TODO(vadimsh): There's probably a way to avoid reparsing includes
    51  				// all the time.
    52  				for _, includeSrc := range includes {
    53  					if _, err := t.Parse(includeSrc); err != nil {
    54  						return nil, err
    55  					}
    56  				}
    57  
    58  				if _, err := t.Parse(body); err != nil {
    59  					return nil, err
    60  				}
    61  				toplevel[name] = t
    62  			}
    63  		}
    64  
    65  		for name, body := range assets {
    66  			if strings.HasPrefix(name, "widgets/") {
    67  				t := template.New(name).Funcs(funcMap)
    68  				if _, err := t.Parse(body); err != nil {
    69  					return nil, err
    70  				}
    71  				toplevel[name] = t
    72  			}
    73  		}
    74  
    75  		return toplevel, nil
    76  	}
    77  }
    78  
    79  // FileSystemLoader returns Loader that loads templates from file system.
    80  //
    81  // The directory with templates is expected to have special structure:
    82  //   - 'pages/' contain all top-level templates that will be loaded
    83  //   - 'includes/' contain templates that will be associated with every top-level template from 'pages/'.
    84  //   - 'widgets/' contain all standalone templates that will be loaded without templates from 'includes/'
    85  //
    86  // Only templates from 'pages/' and 'widgets/' are included in the output map.
    87  func FileSystemLoader(fs fs.FS) Loader {
    88  	return func(c context.Context, funcMap template.FuncMap) (map[string]*template.Template, error) {
    89  		// Read all relevant files into memory. It's ok, they are small.
    90  		files := map[string]string{}
    91  		for _, sub := range []string{"includes", "pages", "widgets"} {
    92  			if err := readFilesInDir(fs, sub, files); err != nil {
    93  				return nil, err
    94  			}
    95  		}
    96  		return AssetsLoader(files)(c, funcMap)
    97  	}
    98  }
    99  
   100  // readFilesInDir loads content of files into a map "file path" => content.
   101  //
   102  // Used only for small HTML templates, and thus it's fine to load them
   103  // in memory.
   104  //
   105  // Does nothing is 'dir' is missing.
   106  func readFilesInDir(fileSystem fs.FS, dir string, out map[string]string) error {
   107  	if _, err := fs.Stat(fileSystem, dir); errors.Is(err, fs.ErrNotExist) {
   108  		return nil
   109  	}
   110  	return fs.WalkDir(fileSystem, dir, func(path string, d fs.DirEntry, err error) error {
   111  		switch {
   112  		case err != nil:
   113  			return err
   114  		case d.IsDir():
   115  			return nil
   116  		default:
   117  			file, err := fileSystem.Open(path)
   118  			if err != nil {
   119  				return err
   120  			}
   121  			defer func() { _ = file.Close() }()
   122  			contents, err := io.ReadAll(file)
   123  			if err != nil {
   124  				return err
   125  			}
   126  			out[filepath.ToSlash(path)] = string(contents)
   127  			return nil
   128  		}
   129  	})
   130  }