github.com/shohhei1126/hugo@v0.42.2-0.20180623210752-3d5928889ad7/hugolib/testhelpers_test.go (about)

     1  package hugolib
     2  
     3  import (
     4  	"path/filepath"
     5  	"testing"
     6  
     7  	"bytes"
     8  	"fmt"
     9  	"regexp"
    10  	"strings"
    11  	"text/template"
    12  
    13  	"github.com/gohugoio/hugo/langs"
    14  	"github.com/sanity-io/litter"
    15  	jww "github.com/spf13/jwalterweatherman"
    16  
    17  	"github.com/gohugoio/hugo/config"
    18  	"github.com/gohugoio/hugo/deps"
    19  	"github.com/spf13/afero"
    20  
    21  	"github.com/gohugoio/hugo/helpers"
    22  	"github.com/gohugoio/hugo/tpl"
    23  	"github.com/spf13/viper"
    24  
    25  	"os"
    26  
    27  	"github.com/gohugoio/hugo/hugofs"
    28  	"github.com/stretchr/testify/assert"
    29  	"github.com/stretchr/testify/require"
    30  )
    31  
    32  const ()
    33  
    34  type sitesBuilder struct {
    35  	Cfg config.Provider
    36  	Fs  *hugofs.Fs
    37  	T   testing.TB
    38  
    39  	logger *jww.Notepad
    40  
    41  	dumper litter.Options
    42  
    43  	// Aka the Hugo server mode.
    44  	running bool
    45  
    46  	H *HugoSites
    47  
    48  	theme string
    49  
    50  	// Default toml
    51  	configFormat string
    52  
    53  	// Default is empty.
    54  	// TODO(bep) revisit this and consider always setting it to something.
    55  	// Consider this in relation to using the BaseFs.PublishFs to all publishing.
    56  	workingDir string
    57  
    58  	// Base data/content
    59  	contentFilePairs  []string
    60  	templateFilePairs []string
    61  	i18nFilePairs     []string
    62  	dataFilePairs     []string
    63  
    64  	// Additional data/content.
    65  	// As in "use the base, but add these on top".
    66  	contentFilePairsAdded  []string
    67  	templateFilePairsAdded []string
    68  	i18nFilePairsAdded     []string
    69  	dataFilePairsAdded     []string
    70  }
    71  
    72  func newTestSitesBuilder(t testing.TB) *sitesBuilder {
    73  	v := viper.New()
    74  	fs := hugofs.NewMem(v)
    75  
    76  	litterOptions := litter.Options{
    77  		HidePrivateFields: true,
    78  		StripPackageNames: true,
    79  		Separator:         " ",
    80  	}
    81  
    82  	return &sitesBuilder{T: t, Fs: fs, configFormat: "toml", dumper: litterOptions}
    83  }
    84  
    85  func (s *sitesBuilder) Running() *sitesBuilder {
    86  	s.running = true
    87  	return s
    88  }
    89  
    90  func (s *sitesBuilder) WithLogger(logger *jww.Notepad) *sitesBuilder {
    91  	s.logger = logger
    92  	return s
    93  }
    94  
    95  func (s *sitesBuilder) WithWorkingDir(dir string) *sitesBuilder {
    96  	s.workingDir = dir
    97  	return s
    98  }
    99  
   100  func (s *sitesBuilder) WithConfigTemplate(data interface{}, format, configTemplate string) *sitesBuilder {
   101  	if format == "" {
   102  		format = "toml"
   103  	}
   104  
   105  	templ, err := template.New("test").Parse(configTemplate)
   106  	if err != nil {
   107  		s.Fatalf("Template parse failed: %s", err)
   108  	}
   109  	var b bytes.Buffer
   110  	templ.Execute(&b, data)
   111  	return s.WithConfigFile(format, b.String())
   112  }
   113  
   114  func (s *sitesBuilder) WithViper(v *viper.Viper) *sitesBuilder {
   115  	loadDefaultSettingsFor(v)
   116  	s.Cfg = v
   117  	return s
   118  }
   119  
   120  func (s *sitesBuilder) WithConfigFile(format, conf string) *sitesBuilder {
   121  	writeSource(s.T, s.Fs, "config."+format, conf)
   122  	s.configFormat = format
   123  	return s
   124  }
   125  
   126  func (s *sitesBuilder) WithThemeConfigFile(format, conf string) *sitesBuilder {
   127  	if s.theme == "" {
   128  		s.theme = "test-theme"
   129  	}
   130  	filename := filepath.Join("themes", s.theme, "config."+format)
   131  	writeSource(s.T, s.Fs, filename, conf)
   132  	return s
   133  }
   134  
   135  func (s *sitesBuilder) WithSourceFile(filename, content string) *sitesBuilder {
   136  	writeSource(s.T, s.Fs, filepath.FromSlash(filename), content)
   137  	return s
   138  }
   139  
   140  const commonConfigSections = `
   141  
   142  [services]
   143  [services.disqus]
   144  shortname = "disqus_shortname"
   145  [services.googleAnalytics]
   146  id = "ga_id"
   147  
   148  [privacy]
   149  [privacy.disqus]
   150  disable = false
   151  [privacy.googleAnalytics]
   152  respectDoNotTrack = true
   153  anonymizeIP = true
   154  [privacy.instagram]
   155  simple = true
   156  [privacy.twitter]
   157  enableDNT = true
   158  [privacy.vimeo]
   159  disable = false
   160  [privacy.youtube]
   161  disable = false
   162  privacyEnhanced = true
   163  
   164  `
   165  
   166  func (s *sitesBuilder) WithSimpleConfigFile() *sitesBuilder {
   167  	var config = `
   168  baseURL = "http://example.com/"
   169  
   170  ` + commonConfigSections
   171  	return s.WithConfigFile("toml", config)
   172  }
   173  
   174  func (s *sitesBuilder) WithDefaultMultiSiteConfig() *sitesBuilder {
   175  	var defaultMultiSiteConfig = `
   176  baseURL = "http://example.com/blog"
   177  
   178  paginate = 1
   179  disablePathToLower = true
   180  defaultContentLanguage = "en"
   181  defaultContentLanguageInSubdir = true
   182  
   183  [permalinks]
   184  other = "/somewhere/else/:filename"
   185  
   186  [blackfriday]
   187  angledQuotes = true
   188  
   189  [Taxonomies]
   190  tag = "tags"
   191  
   192  [Languages]
   193  [Languages.en]
   194  weight = 10
   195  title = "In English"
   196  languageName = "English"
   197  [Languages.en.blackfriday]
   198  angledQuotes = false
   199  [[Languages.en.menu.main]]
   200  url    = "/"
   201  name   = "Home"
   202  weight = 0
   203  
   204  [Languages.fr]
   205  weight = 20
   206  title = "Le Français"
   207  languageName = "Français"
   208  [Languages.fr.Taxonomies]
   209  plaque = "plaques"
   210  
   211  [Languages.nn]
   212  weight = 30
   213  title = "På nynorsk"
   214  languageName = "Nynorsk"
   215  paginatePath = "side"
   216  [Languages.nn.Taxonomies]
   217  lag = "lag"
   218  [[Languages.nn.menu.main]]
   219  url    = "/"
   220  name   = "Heim"
   221  weight = 1
   222  
   223  [Languages.nb]
   224  weight = 40
   225  title = "På bokmål"
   226  languageName = "Bokmål"
   227  paginatePath = "side"
   228  [Languages.nb.Taxonomies]
   229  lag = "lag"
   230  ` + commonConfigSections
   231  
   232  	return s.WithConfigFile("toml", defaultMultiSiteConfig)
   233  
   234  }
   235  
   236  func (s *sitesBuilder) WithContent(filenameContent ...string) *sitesBuilder {
   237  	s.contentFilePairs = append(s.contentFilePairs, filenameContent...)
   238  	return s
   239  }
   240  
   241  func (s *sitesBuilder) WithContentAdded(filenameContent ...string) *sitesBuilder {
   242  	s.contentFilePairsAdded = append(s.contentFilePairsAdded, filenameContent...)
   243  	return s
   244  }
   245  
   246  func (s *sitesBuilder) WithTemplates(filenameContent ...string) *sitesBuilder {
   247  	s.templateFilePairs = append(s.templateFilePairs, filenameContent...)
   248  	return s
   249  }
   250  
   251  func (s *sitesBuilder) WithTemplatesAdded(filenameContent ...string) *sitesBuilder {
   252  	s.templateFilePairsAdded = append(s.templateFilePairsAdded, filenameContent...)
   253  	return s
   254  }
   255  
   256  func (s *sitesBuilder) WithData(filenameContent ...string) *sitesBuilder {
   257  	s.dataFilePairs = append(s.dataFilePairs, filenameContent...)
   258  	return s
   259  }
   260  
   261  func (s *sitesBuilder) WithDataAdded(filenameContent ...string) *sitesBuilder {
   262  	s.dataFilePairsAdded = append(s.dataFilePairsAdded, filenameContent...)
   263  	return s
   264  }
   265  
   266  func (s *sitesBuilder) WithI18n(filenameContent ...string) *sitesBuilder {
   267  	s.i18nFilePairs = append(s.i18nFilePairs, filenameContent...)
   268  	return s
   269  }
   270  
   271  func (s *sitesBuilder) WithI18nAdded(filenameContent ...string) *sitesBuilder {
   272  	s.i18nFilePairsAdded = append(s.i18nFilePairsAdded, filenameContent...)
   273  	return s
   274  }
   275  
   276  func (s *sitesBuilder) writeFilePairs(folder string, filenameContent []string) *sitesBuilder {
   277  	if len(filenameContent)%2 != 0 {
   278  		s.Fatalf("expect filenameContent for %q in pairs (%d)", folder, len(filenameContent))
   279  	}
   280  	for i := 0; i < len(filenameContent); i += 2 {
   281  		filename, content := filenameContent[i], filenameContent[i+1]
   282  		target := folder
   283  		// TODO(bep) clean  up this magic.
   284  		if strings.HasPrefix(filename, folder) {
   285  			target = ""
   286  		}
   287  
   288  		if s.workingDir != "" {
   289  			target = filepath.Join(s.workingDir, target)
   290  		}
   291  
   292  		writeSource(s.T, s.Fs, filepath.Join(target, filename), content)
   293  	}
   294  	return s
   295  }
   296  
   297  func (s *sitesBuilder) CreateSites() *sitesBuilder {
   298  	s.addDefaults()
   299  	s.writeFilePairs("content", s.contentFilePairs)
   300  	s.writeFilePairs("content", s.contentFilePairsAdded)
   301  	s.writeFilePairs("layouts", s.templateFilePairs)
   302  	s.writeFilePairs("layouts", s.templateFilePairsAdded)
   303  	s.writeFilePairs("data", s.dataFilePairs)
   304  	s.writeFilePairs("data", s.dataFilePairsAdded)
   305  	s.writeFilePairs("i18n", s.i18nFilePairs)
   306  	s.writeFilePairs("i18n", s.i18nFilePairsAdded)
   307  
   308  	if s.Cfg == nil {
   309  		cfg, _, err := LoadConfig(ConfigSourceDescriptor{Fs: s.Fs.Source, Filename: "config." + s.configFormat})
   310  		if err != nil {
   311  			s.Fatalf("Failed to load config: %s", err)
   312  		}
   313  		// TODO(bep)
   314  		/*		expectedConfigs := 1
   315  				if s.theme != "" {
   316  					expectedConfigs = 2
   317  				}
   318  				require.Equal(s.T, expectedConfigs, len(configFiles), fmt.Sprintf("Configs: %v", configFiles))
   319  		*/
   320  		s.Cfg = cfg
   321  	}
   322  
   323  	sites, err := NewHugoSites(deps.DepsCfg{Fs: s.Fs, Cfg: s.Cfg, Logger: s.logger, Running: s.running})
   324  	if err != nil {
   325  		s.Fatalf("Failed to create sites: %s", err)
   326  	}
   327  	s.H = sites
   328  
   329  	return s
   330  }
   331  
   332  func (s *sitesBuilder) Build(cfg BuildCfg) *sitesBuilder {
   333  	return s.build(cfg, false)
   334  }
   335  
   336  func (s *sitesBuilder) BuildFail(cfg BuildCfg) *sitesBuilder {
   337  	return s.build(cfg, true)
   338  }
   339  
   340  func (s *sitesBuilder) build(cfg BuildCfg, shouldFail bool) *sitesBuilder {
   341  	if s.H == nil {
   342  		s.CreateSites()
   343  	}
   344  
   345  	err := s.H.Build(cfg)
   346  	if err == nil {
   347  		logErrorCount := s.H.NumLogErrors()
   348  		if logErrorCount > 0 {
   349  			err = fmt.Errorf("logged %d errors", logErrorCount)
   350  		}
   351  	}
   352  	if err != nil && !shouldFail {
   353  		s.Fatalf("Build failed: %s", err)
   354  	} else if err == nil && shouldFail {
   355  		s.Fatalf("Expected error")
   356  	}
   357  
   358  	return s
   359  }
   360  
   361  func (s *sitesBuilder) addDefaults() {
   362  
   363  	var (
   364  		contentTemplate = `---
   365  title: doc1
   366  weight: 1
   367  tags:
   368   - tag1
   369  date: "2018-02-28"
   370  ---
   371  # doc1
   372  *some "content"*
   373  
   374  {{< shortcode >}}
   375  
   376  {{< lingo >}}
   377  `
   378  
   379  		defaultContent = []string{
   380  			"content/sect/doc1.en.md", contentTemplate,
   381  			"content/sect/doc1.fr.md", contentTemplate,
   382  			"content/sect/doc1.nb.md", contentTemplate,
   383  			"content/sect/doc1.nn.md", contentTemplate,
   384  		}
   385  
   386  		defaultTemplates = []string{
   387  			"_default/single.html", "Single: {{ .Title }}|{{ i18n \"hello\" }}|{{.Lang}}|{{ .Content }}",
   388  			"_default/list.html", "{{ $p := .Paginator }}List Page {{ $p.PageNumber }}: {{ .Title }}|{{ i18n \"hello\" }}|{{ .Permalink }}|Pager: {{ template \"_internal/pagination.html\" . }}",
   389  			"index.html", "{{ $p := .Paginator }}Default Home Page {{ $p.PageNumber }}: {{ .Title }}|{{ .IsHome }}|{{ i18n \"hello\" }}|{{ .Permalink }}|{{  .Site.Data.hugo.slogan }}",
   390  			"index.fr.html", "{{ $p := .Paginator }}French Home Page {{ $p.PageNumber }}: {{ .Title }}|{{ .IsHome }}|{{ i18n \"hello\" }}|{{ .Permalink }}|{{  .Site.Data.hugo.slogan }}",
   391  
   392  			// Shortcodes
   393  			"shortcodes/shortcode.html", "Shortcode: {{ i18n \"hello\" }}",
   394  			// A shortcode in multiple languages
   395  			"shortcodes/lingo.html", "LingoDefault",
   396  			"shortcodes/lingo.fr.html", "LingoFrench",
   397  		}
   398  
   399  		defaultI18n = []string{
   400  			"en.yaml", `
   401  hello:
   402    other: "Hello"
   403  `,
   404  			"fr.yaml", `
   405  hello:
   406    other: "Bonjour"
   407  `,
   408  		}
   409  
   410  		defaultData = []string{
   411  			"hugo.toml", "slogan = \"Hugo Rocks!\"",
   412  		}
   413  	)
   414  
   415  	if len(s.contentFilePairs) == 0 {
   416  		s.writeFilePairs("content", defaultContent)
   417  	}
   418  	if len(s.templateFilePairs) == 0 {
   419  		s.writeFilePairs("layouts", defaultTemplates)
   420  	}
   421  	if len(s.dataFilePairs) == 0 {
   422  		s.writeFilePairs("data", defaultData)
   423  	}
   424  	if len(s.i18nFilePairs) == 0 {
   425  		s.writeFilePairs("i18n", defaultI18n)
   426  	}
   427  }
   428  
   429  func (s *sitesBuilder) Fatalf(format string, args ...interface{}) {
   430  	Fatalf(s.T, format, args...)
   431  }
   432  
   433  func Fatalf(t testing.TB, format string, args ...interface{}) {
   434  	trace := strings.Join(assert.CallerInfo(), "\n\r\t\t\t")
   435  	format = format + "\n%s"
   436  	args = append(args, trace)
   437  	t.Fatalf(format, args...)
   438  }
   439  
   440  func (s *sitesBuilder) AssertFileContent(filename string, matches ...string) {
   441  	content := readDestination(s.T, s.Fs, filename)
   442  	for _, match := range matches {
   443  		if !strings.Contains(content, match) {
   444  			s.Fatalf("No match for %q in content for %s\n%s", match, filename, content)
   445  		}
   446  	}
   447  }
   448  
   449  func (s *sitesBuilder) AssertObject(expected string, object interface{}) {
   450  	got := s.dumper.Sdump(object)
   451  	expected = strings.TrimSpace(expected)
   452  
   453  	if expected != got {
   454  		fmt.Println(got)
   455  		diff := helpers.DiffStrings(expected, got)
   456  		s.Fatalf("diff:\n%s\nexpected\n%s\ngot\n%s", diff, expected, got)
   457  	}
   458  }
   459  
   460  func (s *sitesBuilder) AssertFileContentRe(filename string, matches ...string) {
   461  	content := readDestination(s.T, s.Fs, filename)
   462  	for _, match := range matches {
   463  		r := regexp.MustCompile(match)
   464  		if !r.MatchString(content) {
   465  			s.Fatalf("No match for %q in content for %s\n%q", match, filename, content)
   466  		}
   467  	}
   468  }
   469  
   470  func (s *sitesBuilder) CheckExists(filename string) bool {
   471  	return destinationExists(s.Fs, filepath.Clean(filename))
   472  }
   473  
   474  type testHelper struct {
   475  	Cfg config.Provider
   476  	Fs  *hugofs.Fs
   477  	T   testing.TB
   478  }
   479  
   480  func (th testHelper) assertFileContent(filename string, matches ...string) {
   481  	filename = th.replaceDefaultContentLanguageValue(filename)
   482  	content := readDestination(th.T, th.Fs, filename)
   483  	for _, match := range matches {
   484  		match = th.replaceDefaultContentLanguageValue(match)
   485  		require.True(th.T, strings.Contains(content, match), fmt.Sprintf("File no match for\n%q in\n%q:\n%s", strings.Replace(match, "%", "%%", -1), filename, strings.Replace(content, "%", "%%", -1)))
   486  	}
   487  }
   488  
   489  func (th testHelper) assertFileContentRegexp(filename string, matches ...string) {
   490  	filename = th.replaceDefaultContentLanguageValue(filename)
   491  	content := readDestination(th.T, th.Fs, filename)
   492  	for _, match := range matches {
   493  		match = th.replaceDefaultContentLanguageValue(match)
   494  		r := regexp.MustCompile(match)
   495  		require.True(th.T, r.MatchString(content), fmt.Sprintf("File no match for\n%q in\n%q:\n%s", strings.Replace(match, "%", "%%", -1), filename, strings.Replace(content, "%", "%%", -1)))
   496  	}
   497  }
   498  
   499  func (th testHelper) assertFileNotExist(filename string) {
   500  	exists, err := helpers.Exists(filename, th.Fs.Destination)
   501  	require.NoError(th.T, err)
   502  	require.False(th.T, exists)
   503  }
   504  
   505  func (th testHelper) replaceDefaultContentLanguageValue(value string) string {
   506  	defaultInSubDir := th.Cfg.GetBool("defaultContentLanguageInSubDir")
   507  	replace := th.Cfg.GetString("defaultContentLanguage") + "/"
   508  
   509  	if !defaultInSubDir {
   510  		value = strings.Replace(value, replace, "", 1)
   511  
   512  	}
   513  	return value
   514  }
   515  
   516  func newTestPathSpec(fs *hugofs.Fs, v *viper.Viper) *helpers.PathSpec {
   517  	l := langs.NewDefaultLanguage(v)
   518  	ps, _ := helpers.NewPathSpec(fs, l)
   519  	return ps
   520  }
   521  
   522  func newTestDefaultPathSpec() *helpers.PathSpec {
   523  	v := viper.New()
   524  	// Easier to reason about in tests.
   525  	v.Set("disablePathToLower", true)
   526  	v.Set("contentDir", "content")
   527  	v.Set("dataDir", "data")
   528  	v.Set("i18nDir", "i18n")
   529  	v.Set("layoutDir", "layouts")
   530  	v.Set("archetypeDir", "archetypes")
   531  	fs := hugofs.NewDefault(v)
   532  	ps, _ := helpers.NewPathSpec(fs, v)
   533  	return ps
   534  }
   535  
   536  func newTestCfg() (*viper.Viper, *hugofs.Fs) {
   537  
   538  	v := viper.New()
   539  	fs := hugofs.NewMem(v)
   540  
   541  	v.SetFs(fs.Source)
   542  
   543  	loadDefaultSettingsFor(v)
   544  
   545  	// Default is false, but true is easier to use as default in tests
   546  	v.Set("defaultContentLanguageInSubdir", true)
   547  
   548  	return v, fs
   549  
   550  }
   551  
   552  // newTestSite creates a new site in the  English language with in-memory Fs.
   553  // The site will have a template system loaded and ready to use.
   554  // Note: This is only used in single site tests.
   555  func newTestSite(t testing.TB, configKeyValues ...interface{}) *Site {
   556  
   557  	cfg, fs := newTestCfg()
   558  
   559  	for i := 0; i < len(configKeyValues); i += 2 {
   560  		cfg.Set(configKeyValues[i].(string), configKeyValues[i+1])
   561  	}
   562  
   563  	d := deps.DepsCfg{Language: langs.NewLanguage("en", cfg), Fs: fs, Cfg: cfg}
   564  
   565  	s, err := NewSiteForCfg(d)
   566  
   567  	if err != nil {
   568  		Fatalf(t, "Failed to create Site: %s", err)
   569  	}
   570  	return s
   571  }
   572  
   573  func newTestSitesFromConfig(t testing.TB, afs afero.Fs, tomlConfig string, layoutPathContentPairs ...string) (testHelper, *HugoSites) {
   574  	if len(layoutPathContentPairs)%2 != 0 {
   575  		Fatalf(t, "Layouts must be provided in pairs")
   576  	}
   577  
   578  	writeToFs(t, afs, "config.toml", tomlConfig)
   579  
   580  	cfg, err := LoadConfigDefault(afs)
   581  	require.NoError(t, err)
   582  
   583  	fs := hugofs.NewFrom(afs, cfg)
   584  	th := testHelper{cfg, fs, t}
   585  
   586  	for i := 0; i < len(layoutPathContentPairs); i += 2 {
   587  		writeSource(t, fs, layoutPathContentPairs[i], layoutPathContentPairs[i+1])
   588  	}
   589  
   590  	h, err := NewHugoSites(deps.DepsCfg{Fs: fs, Cfg: cfg})
   591  
   592  	require.NoError(t, err)
   593  
   594  	return th, h
   595  }
   596  
   597  func newTestSitesFromConfigWithDefaultTemplates(t testing.TB, tomlConfig string) (testHelper, *HugoSites) {
   598  	return newTestSitesFromConfig(t, afero.NewMemMapFs(), tomlConfig,
   599  		"layouts/_default/single.html", "Single|{{ .Title }}|{{ .Content }}",
   600  		"layouts/_default/list.html", "List|{{ .Title }}|{{ .Content }}",
   601  		"layouts/_default/terms.html", "Terms List|{{ .Title }}|{{ .Content }}",
   602  	)
   603  }
   604  
   605  func createWithTemplateFromNameValues(additionalTemplates ...string) func(templ tpl.TemplateHandler) error {
   606  
   607  	return func(templ tpl.TemplateHandler) error {
   608  		for i := 0; i < len(additionalTemplates); i += 2 {
   609  			err := templ.AddTemplate(additionalTemplates[i], additionalTemplates[i+1])
   610  			if err != nil {
   611  				return err
   612  			}
   613  		}
   614  		return nil
   615  	}
   616  }
   617  
   618  func buildSingleSite(t testing.TB, depsCfg deps.DepsCfg, buildCfg BuildCfg) *Site {
   619  	return buildSingleSiteExpected(t, false, depsCfg, buildCfg)
   620  }
   621  
   622  func buildSingleSiteExpected(t testing.TB, expectBuildError bool, depsCfg deps.DepsCfg, buildCfg BuildCfg) *Site {
   623  	h, err := NewHugoSites(depsCfg)
   624  
   625  	require.NoError(t, err)
   626  	require.Len(t, h.Sites, 1)
   627  
   628  	if expectBuildError {
   629  		require.Error(t, h.Build(buildCfg))
   630  		return nil
   631  
   632  	}
   633  
   634  	require.NoError(t, h.Build(buildCfg))
   635  
   636  	return h.Sites[0]
   637  }
   638  
   639  func writeSourcesToSource(t *testing.T, base string, fs *hugofs.Fs, sources ...[2]string) {
   640  	for _, src := range sources {
   641  		writeSource(t, fs, filepath.Join(base, src[0]), src[1])
   642  	}
   643  }
   644  
   645  func dumpPages(pages ...*Page) {
   646  	for i, p := range pages {
   647  		fmt.Printf("%d: Kind: %s Title: %-10s RelPermalink: %-10s Path: %-10s sections: %s Len Sections(): %d\n",
   648  			i+1,
   649  			p.Kind, p.title, p.RelPermalink(), p.Path(), p.sections, len(p.Sections()))
   650  	}
   651  }
   652  
   653  func isCI() bool {
   654  	return os.Getenv("CI") != ""
   655  }