github.com/gohugoio/hugo@v0.88.1/resources/resource_transformers/postcss/postcss.go (about)

     1  // Copyright 2018 The Hugo Authors. All rights reserved.
     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  // http://www.apache.org/licenses/LICENSE-2.0
     7  //
     8  // Unless required by applicable law or agreed to in writing, software
     9  // distributed under the License is distributed on an "AS IS" BASIS,
    10  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    11  // See the License for the specific language governing permissions and
    12  // limitations under the License.
    13  
    14  package postcss
    15  
    16  import (
    17  	"bytes"
    18  	"crypto/sha256"
    19  	"encoding/hex"
    20  	"io"
    21  	"io/ioutil"
    22  	"path"
    23  	"path/filepath"
    24  	"regexp"
    25  	"strconv"
    26  	"strings"
    27  
    28  	"github.com/cli/safeexec"
    29  
    30  	"github.com/gohugoio/hugo/common/hexec"
    31  
    32  	"github.com/gohugoio/hugo/common/hugo"
    33  
    34  	"github.com/gohugoio/hugo/common/loggers"
    35  
    36  	"github.com/gohugoio/hugo/resources/internal"
    37  	"github.com/spf13/afero"
    38  	"github.com/spf13/cast"
    39  
    40  	"github.com/gohugoio/hugo/hugofs"
    41  	"github.com/pkg/errors"
    42  
    43  	"github.com/mitchellh/mapstructure"
    44  
    45  	"github.com/gohugoio/hugo/common/herrors"
    46  	"github.com/gohugoio/hugo/resources"
    47  	"github.com/gohugoio/hugo/resources/resource"
    48  )
    49  
    50  const importIdentifier = "@import"
    51  
    52  var cssSyntaxErrorRe = regexp.MustCompile(`> (\d+) \|`)
    53  
    54  var shouldImportRe = regexp.MustCompile(`^@import ["'].*["'];?\s*(/\*.*\*/)?$`)
    55  
    56  // New creates a new Client with the given specification.
    57  func New(rs *resources.Spec) *Client {
    58  	return &Client{rs: rs}
    59  }
    60  
    61  func DecodeOptions(m map[string]interface{}) (opts Options, err error) {
    62  	if m == nil {
    63  		return
    64  	}
    65  	err = mapstructure.WeakDecode(m, &opts)
    66  
    67  	if !opts.NoMap {
    68  		// There was for a long time a discrepancy between documentation and
    69  		// implementation for the noMap property, so we need to support both
    70  		// camel and snake case.
    71  		opts.NoMap = cast.ToBool(m["no-map"])
    72  	}
    73  
    74  	return
    75  }
    76  
    77  // Client is the client used to do PostCSS transformations.
    78  type Client struct {
    79  	rs *resources.Spec
    80  }
    81  
    82  // Process transforms the given Resource with the PostCSS processor.
    83  func (c *Client) Process(res resources.ResourceTransformer, options Options) (resource.Resource, error) {
    84  	return res.Transform(&postcssTransformation{rs: c.rs, options: options})
    85  }
    86  
    87  // Some of the options from https://github.com/postcss/postcss-cli
    88  type Options struct {
    89  
    90  	// Set a custom path to look for a config file.
    91  	Config string
    92  
    93  	NoMap bool // Disable the default inline sourcemaps
    94  
    95  	// Enable inlining of @import statements.
    96  	// Does so recursively, but currently once only per file;
    97  	// that is, it's not possible to import the same file in
    98  	// different scopes (root, media query...)
    99  	// Note that this import routine does not care about the CSS spec,
   100  	// so you can have @import anywhere in the file.
   101  	InlineImports bool
   102  
   103  	// Options for when not using a config file
   104  	Use         string // List of postcss plugins to use
   105  	Parser      string //  Custom postcss parser
   106  	Stringifier string // Custom postcss stringifier
   107  	Syntax      string // Custom postcss syntax
   108  }
   109  
   110  func (opts Options) toArgs() []string {
   111  	var args []string
   112  	if opts.NoMap {
   113  		args = append(args, "--no-map")
   114  	}
   115  	if opts.Use != "" {
   116  		args = append(args, "--use", opts.Use)
   117  	}
   118  	if opts.Parser != "" {
   119  		args = append(args, "--parser", opts.Parser)
   120  	}
   121  	if opts.Stringifier != "" {
   122  		args = append(args, "--stringifier", opts.Stringifier)
   123  	}
   124  	if opts.Syntax != "" {
   125  		args = append(args, "--syntax", opts.Syntax)
   126  	}
   127  	return args
   128  }
   129  
   130  type postcssTransformation struct {
   131  	options Options
   132  	rs      *resources.Spec
   133  }
   134  
   135  func (t *postcssTransformation) Key() internal.ResourceTransformationKey {
   136  	return internal.NewResourceTransformationKey("postcss", t.options)
   137  }
   138  
   139  // Transform shells out to postcss-cli to do the heavy lifting.
   140  // For this to work, you need some additional tools. To install them globally:
   141  // npm install -g postcss-cli
   142  // npm install -g autoprefixer
   143  func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
   144  	const localPostCSSPath = "node_modules/.bin/"
   145  	const binaryName = "postcss"
   146  
   147  	// Try first in the project's node_modules.
   148  	csiBinPath := filepath.Join(t.rs.WorkingDir, localPostCSSPath, binaryName)
   149  
   150  	binary := csiBinPath
   151  
   152  	if _, err := safeexec.LookPath(binary); err != nil {
   153  		// Try PATH
   154  		binary = binaryName
   155  		if _, err := safeexec.LookPath(binary); err != nil {
   156  			// This may be on a CI server etc. Will fall back to pre-built assets.
   157  			return herrors.ErrFeatureNotAvailable
   158  		}
   159  	}
   160  
   161  	var configFile string
   162  	logger := t.rs.Logger
   163  
   164  	if t.options.Config != "" {
   165  		configFile = t.options.Config
   166  	} else {
   167  		configFile = "postcss.config.js"
   168  	}
   169  
   170  	configFile = filepath.Clean(configFile)
   171  
   172  	// We need an absolute filename to the config file.
   173  	if !filepath.IsAbs(configFile) {
   174  		configFile = t.rs.BaseFs.ResolveJSConfigFile(configFile)
   175  		if configFile == "" && t.options.Config != "" {
   176  			// Only fail if the user specified config file is not found.
   177  			return errors.Errorf("postcss config %q not found:", configFile)
   178  		}
   179  	}
   180  
   181  	var cmdArgs []string
   182  
   183  	if configFile != "" {
   184  		logger.Infoln("postcss: use config file", configFile)
   185  		cmdArgs = []string{"--config", configFile}
   186  	}
   187  
   188  	if optArgs := t.options.toArgs(); len(optArgs) > 0 {
   189  		cmdArgs = append(cmdArgs, optArgs...)
   190  	}
   191  
   192  	cmd, err := hexec.SafeCommand(binary, cmdArgs...)
   193  	if err != nil {
   194  		return err
   195  	}
   196  
   197  	var errBuf bytes.Buffer
   198  	infoW := loggers.LoggerToWriterWithPrefix(logger.Info(), "postcss")
   199  
   200  	cmd.Stdout = ctx.To
   201  	cmd.Stderr = io.MultiWriter(infoW, &errBuf)
   202  
   203  	cmd.Env = hugo.GetExecEnviron(t.rs.WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs)
   204  
   205  	stdin, err := cmd.StdinPipe()
   206  	if err != nil {
   207  		return err
   208  	}
   209  
   210  	src := ctx.From
   211  
   212  	imp := newImportResolver(
   213  		ctx.From,
   214  		ctx.InPath,
   215  		t.rs.Assets.Fs, t.rs.Logger,
   216  	)
   217  
   218  	if t.options.InlineImports {
   219  		var err error
   220  		src, err = imp.resolve()
   221  		if err != nil {
   222  			return err
   223  		}
   224  	}
   225  
   226  	go func() {
   227  		defer stdin.Close()
   228  		io.Copy(stdin, src)
   229  	}()
   230  
   231  	err = cmd.Run()
   232  	if err != nil {
   233  		return imp.toFileError(errBuf.String())
   234  	}
   235  
   236  	return nil
   237  }
   238  
   239  type fileOffset struct {
   240  	Filename string
   241  	Offset   int
   242  }
   243  
   244  type importResolver struct {
   245  	r      io.Reader
   246  	inPath string
   247  
   248  	contentSeen map[string]bool
   249  	linemap     map[int]fileOffset
   250  	fs          afero.Fs
   251  	logger      loggers.Logger
   252  }
   253  
   254  func newImportResolver(r io.Reader, inPath string, fs afero.Fs, logger loggers.Logger) *importResolver {
   255  	return &importResolver{
   256  		r:      r,
   257  		inPath: inPath,
   258  		fs:     fs, logger: logger,
   259  		linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool),
   260  	}
   261  }
   262  
   263  func (imp *importResolver) contentHash(filename string) ([]byte, string) {
   264  	b, err := afero.ReadFile(imp.fs, filename)
   265  	if err != nil {
   266  		return nil, ""
   267  	}
   268  	h := sha256.New()
   269  	h.Write(b)
   270  	return b, hex.EncodeToString(h.Sum(nil))
   271  }
   272  
   273  func (imp *importResolver) importRecursive(
   274  	lineNum int,
   275  	content string,
   276  	inPath string) (int, string, error) {
   277  	basePath := path.Dir(inPath)
   278  
   279  	var replacements []string
   280  	lines := strings.Split(content, "\n")
   281  
   282  	trackLine := func(i, offset int, line string) {
   283  		// TODO(bep) this is not very efficient.
   284  		imp.linemap[i+lineNum] = fileOffset{Filename: inPath, Offset: offset}
   285  	}
   286  
   287  	i := 0
   288  	for offset, line := range lines {
   289  		i++
   290  		line = strings.TrimSpace(line)
   291  
   292  		if !imp.shouldImport(line) {
   293  			trackLine(i, offset, line)
   294  		} else {
   295  			i--
   296  			path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';")
   297  			filename := filepath.Join(basePath, path)
   298  			importContent, hash := imp.contentHash(filename)
   299  			if importContent == nil {
   300  				trackLine(i, offset, "ERROR")
   301  				imp.logger.Warnf("postcss: Failed to resolve CSS @import in %q for path %q", inPath, filename)
   302  				continue
   303  			}
   304  
   305  			if imp.contentSeen[hash] {
   306  				i++
   307  				// Just replace the line with an empty string.
   308  				replacements = append(replacements, []string{line, ""}...)
   309  				trackLine(i, offset, "IMPORT")
   310  				continue
   311  			}
   312  
   313  			imp.contentSeen[hash] = true
   314  
   315  			// Handle recursive imports.
   316  			l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename))
   317  			if err != nil {
   318  				return 0, "", err
   319  			}
   320  
   321  			trackLine(i, offset, line)
   322  
   323  			i += l
   324  
   325  			importContent = []byte(nested)
   326  
   327  			replacements = append(replacements, []string{line, string(importContent)}...)
   328  		}
   329  	}
   330  
   331  	if len(replacements) > 0 {
   332  		repl := strings.NewReplacer(replacements...)
   333  		content = repl.Replace(content)
   334  	}
   335  
   336  	return i, content, nil
   337  }
   338  
   339  func (imp *importResolver) resolve() (io.Reader, error) {
   340  	const importIdentifier = "@import"
   341  
   342  	content, err := ioutil.ReadAll(imp.r)
   343  	if err != nil {
   344  		return nil, err
   345  	}
   346  
   347  	contents := string(content)
   348  
   349  	_, newContent, err := imp.importRecursive(0, contents, imp.inPath)
   350  	if err != nil {
   351  		return nil, err
   352  	}
   353  
   354  	return strings.NewReader(newContent), nil
   355  }
   356  
   357  // See https://www.w3schools.com/cssref/pr_import_rule.asp
   358  // We currently only support simple file imports, no urls, no media queries.
   359  // So this is OK:
   360  //     @import "navigation.css";
   361  // This is not:
   362  //     @import url("navigation.css");
   363  //     @import "mobstyle.css" screen and (max-width: 768px);
   364  func (imp *importResolver) shouldImport(s string) bool {
   365  	if !strings.HasPrefix(s, importIdentifier) {
   366  		return false
   367  	}
   368  	if strings.Contains(s, "url(") {
   369  		return false
   370  	}
   371  
   372  	return shouldImportRe.MatchString(s)
   373  }
   374  
   375  func (imp *importResolver) toFileError(output string) error {
   376  	inErr := errors.New(strings.TrimSpace(output))
   377  
   378  	match := cssSyntaxErrorRe.FindStringSubmatch(output)
   379  	if match == nil {
   380  		return inErr
   381  	}
   382  
   383  	lineNum, err := strconv.Atoi(match[1])
   384  	if err != nil {
   385  		return inErr
   386  	}
   387  
   388  	file, ok := imp.linemap[lineNum]
   389  	if !ok {
   390  		return inErr
   391  	}
   392  
   393  	fi, err := imp.fs.Stat(file.Filename)
   394  	if err != nil {
   395  		return inErr
   396  	}
   397  	realFilename := fi.(hugofs.FileMetaInfo).Meta().Filename
   398  
   399  	ferr := herrors.NewFileError("css", -1, file.Offset+1, 1, inErr)
   400  
   401  	werr, ok := herrors.WithFileContextForFile(ferr, realFilename, file.Filename, imp.fs, herrors.SimpleLineMatcher)
   402  
   403  	if !ok {
   404  		return ferr
   405  	}
   406  
   407  	return werr
   408  }