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