github.com/graemephi/kahugo@v0.62.3-0.20211121071557-d78c0423784d/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")
   117  		args = append(args, strings.Fields(opts.Use)...)
   118  	}
   119  	if opts.Parser != "" {
   120  		args = append(args, "--parser", opts.Parser)
   121  	}
   122  	if opts.Stringifier != "" {
   123  		args = append(args, "--stringifier", opts.Stringifier)
   124  	}
   125  	if opts.Syntax != "" {
   126  		args = append(args, "--syntax", opts.Syntax)
   127  	}
   128  	return args
   129  }
   130  
   131  type postcssTransformation struct {
   132  	options Options
   133  	rs      *resources.Spec
   134  }
   135  
   136  func (t *postcssTransformation) Key() internal.ResourceTransformationKey {
   137  	return internal.NewResourceTransformationKey("postcss", t.options)
   138  }
   139  
   140  // Transform shells out to postcss-cli to do the heavy lifting.
   141  // For this to work, you need some additional tools. To install them globally:
   142  // npm install -g postcss-cli
   143  // npm install -g autoprefixer
   144  func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
   145  	const localPostCSSPath = "node_modules/.bin/"
   146  	const binaryName = "postcss"
   147  
   148  	// Try first in the project's node_modules.
   149  	csiBinPath := filepath.Join(t.rs.WorkingDir, localPostCSSPath, binaryName)
   150  
   151  	binary := csiBinPath
   152  
   153  	if _, err := safeexec.LookPath(binary); err != nil {
   154  		// Try PATH
   155  		binary = binaryName
   156  		if _, err := safeexec.LookPath(binary); err != nil {
   157  			// This may be on a CI server etc. Will fall back to pre-built assets.
   158  			return herrors.ErrFeatureNotAvailable
   159  		}
   160  	}
   161  
   162  	var configFile string
   163  	logger := t.rs.Logger
   164  
   165  	if t.options.Config != "" {
   166  		configFile = t.options.Config
   167  	} else {
   168  		configFile = "postcss.config.js"
   169  	}
   170  
   171  	configFile = filepath.Clean(configFile)
   172  
   173  	// We need an absolute filename to the config file.
   174  	if !filepath.IsAbs(configFile) {
   175  		configFile = t.rs.BaseFs.ResolveJSConfigFile(configFile)
   176  		if configFile == "" && t.options.Config != "" {
   177  			// Only fail if the user specified config file is not found.
   178  			return errors.Errorf("postcss config %q not found:", configFile)
   179  		}
   180  	}
   181  
   182  	var cmdArgs []string
   183  
   184  	if configFile != "" {
   185  		logger.Infoln("postcss: use config file", configFile)
   186  		cmdArgs = []string{"--config", configFile}
   187  	}
   188  
   189  	if optArgs := t.options.toArgs(); len(optArgs) > 0 {
   190  		cmdArgs = append(cmdArgs, optArgs...)
   191  	}
   192  
   193  	cmd, err := hexec.SafeCommand(binary, cmdArgs...)
   194  	if err != nil {
   195  		return err
   196  	}
   197  
   198  	var errBuf bytes.Buffer
   199  	infoW := loggers.LoggerToWriterWithPrefix(logger.Info(), "postcss")
   200  
   201  	cmd.Stdout = ctx.To
   202  	cmd.Stderr = io.MultiWriter(infoW, &errBuf)
   203  
   204  	cmd.Env = hugo.GetExecEnviron(t.rs.WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs)
   205  
   206  	stdin, err := cmd.StdinPipe()
   207  	if err != nil {
   208  		return err
   209  	}
   210  
   211  	src := ctx.From
   212  
   213  	imp := newImportResolver(
   214  		ctx.From,
   215  		ctx.InPath,
   216  		t.rs.Assets.Fs, t.rs.Logger,
   217  	)
   218  
   219  	if t.options.InlineImports {
   220  		var err error
   221  		src, err = imp.resolve()
   222  		if err != nil {
   223  			return err
   224  		}
   225  	}
   226  
   227  	go func() {
   228  		defer stdin.Close()
   229  		io.Copy(stdin, src)
   230  	}()
   231  
   232  	err = cmd.Run()
   233  	if err != nil {
   234  		return imp.toFileError(errBuf.String())
   235  	}
   236  
   237  	return nil
   238  }
   239  
   240  type fileOffset struct {
   241  	Filename string
   242  	Offset   int
   243  }
   244  
   245  type importResolver struct {
   246  	r      io.Reader
   247  	inPath string
   248  
   249  	contentSeen map[string]bool
   250  	linemap     map[int]fileOffset
   251  	fs          afero.Fs
   252  	logger      loggers.Logger
   253  }
   254  
   255  func newImportResolver(r io.Reader, inPath string, fs afero.Fs, logger loggers.Logger) *importResolver {
   256  	return &importResolver{
   257  		r:      r,
   258  		inPath: inPath,
   259  		fs:     fs, logger: logger,
   260  		linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool),
   261  	}
   262  }
   263  
   264  func (imp *importResolver) contentHash(filename string) ([]byte, string) {
   265  	b, err := afero.ReadFile(imp.fs, filename)
   266  	if err != nil {
   267  		return nil, ""
   268  	}
   269  	h := sha256.New()
   270  	h.Write(b)
   271  	return b, hex.EncodeToString(h.Sum(nil))
   272  }
   273  
   274  func (imp *importResolver) importRecursive(
   275  	lineNum int,
   276  	content string,
   277  	inPath string) (int, string, error) {
   278  	basePath := path.Dir(inPath)
   279  
   280  	var replacements []string
   281  	lines := strings.Split(content, "\n")
   282  
   283  	trackLine := func(i, offset int, line string) {
   284  		// TODO(bep) this is not very efficient.
   285  		imp.linemap[i+lineNum] = fileOffset{Filename: inPath, Offset: offset}
   286  	}
   287  
   288  	i := 0
   289  	for offset, line := range lines {
   290  		i++
   291  		line = strings.TrimSpace(line)
   292  
   293  		if !imp.shouldImport(line) {
   294  			trackLine(i, offset, line)
   295  		} else {
   296  			i--
   297  			path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';")
   298  			filename := filepath.Join(basePath, path)
   299  			importContent, hash := imp.contentHash(filename)
   300  			if importContent == nil {
   301  				trackLine(i, offset, "ERROR")
   302  				imp.logger.Warnf("postcss: Failed to resolve CSS @import in %q for path %q", inPath, filename)
   303  				continue
   304  			}
   305  
   306  			if imp.contentSeen[hash] {
   307  				i++
   308  				// Just replace the line with an empty string.
   309  				replacements = append(replacements, []string{line, ""}...)
   310  				trackLine(i, offset, "IMPORT")
   311  				continue
   312  			}
   313  
   314  			imp.contentSeen[hash] = true
   315  
   316  			// Handle recursive imports.
   317  			l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename))
   318  			if err != nil {
   319  				return 0, "", err
   320  			}
   321  
   322  			trackLine(i, offset, line)
   323  
   324  			i += l
   325  
   326  			importContent = []byte(nested)
   327  
   328  			replacements = append(replacements, []string{line, string(importContent)}...)
   329  		}
   330  	}
   331  
   332  	if len(replacements) > 0 {
   333  		repl := strings.NewReplacer(replacements...)
   334  		content = repl.Replace(content)
   335  	}
   336  
   337  	return i, content, nil
   338  }
   339  
   340  func (imp *importResolver) resolve() (io.Reader, error) {
   341  	const importIdentifier = "@import"
   342  
   343  	content, err := ioutil.ReadAll(imp.r)
   344  	if err != nil {
   345  		return nil, err
   346  	}
   347  
   348  	contents := string(content)
   349  
   350  	_, newContent, err := imp.importRecursive(0, contents, imp.inPath)
   351  	if err != nil {
   352  		return nil, err
   353  	}
   354  
   355  	return strings.NewReader(newContent), nil
   356  }
   357  
   358  // See https://www.w3schools.com/cssref/pr_import_rule.asp
   359  // We currently only support simple file imports, no urls, no media queries.
   360  // So this is OK:
   361  //     @import "navigation.css";
   362  // This is not:
   363  //     @import url("navigation.css");
   364  //     @import "mobstyle.css" screen and (max-width: 768px);
   365  func (imp *importResolver) shouldImport(s string) bool {
   366  	if !strings.HasPrefix(s, importIdentifier) {
   367  		return false
   368  	}
   369  	if strings.Contains(s, "url(") {
   370  		return false
   371  	}
   372  
   373  	return shouldImportRe.MatchString(s)
   374  }
   375  
   376  func (imp *importResolver) toFileError(output string) error {
   377  	inErr := errors.New(strings.TrimSpace(output))
   378  
   379  	match := cssSyntaxErrorRe.FindStringSubmatch(output)
   380  	if match == nil {
   381  		return inErr
   382  	}
   383  
   384  	lineNum, err := strconv.Atoi(match[1])
   385  	if err != nil {
   386  		return inErr
   387  	}
   388  
   389  	file, ok := imp.linemap[lineNum]
   390  	if !ok {
   391  		return inErr
   392  	}
   393  
   394  	fi, err := imp.fs.Stat(file.Filename)
   395  	if err != nil {
   396  		return inErr
   397  	}
   398  	realFilename := fi.(hugofs.FileMetaInfo).Meta().Filename
   399  
   400  	ferr := herrors.NewFileError("css", -1, file.Offset+1, 1, inErr)
   401  
   402  	werr, ok := herrors.WithFileContextForFile(ferr, realFilename, file.Filename, imp.fs, herrors.SimpleLineMatcher)
   403  
   404  	if !ok {
   405  		return ferr
   406  	}
   407  
   408  	return werr
   409  }