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