github.com/neohugo/neohugo@v0.123.8/resources/resource_transformers/postcss/postcss.go (about)

     1  // Copyright 2024 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  	"errors"
    21  	"fmt"
    22  	"io"
    23  	"path"
    24  	"path/filepath"
    25  	"regexp"
    26  	"strconv"
    27  	"strings"
    28  
    29  	"github.com/neohugo/neohugo/common/collections"
    30  	"github.com/neohugo/neohugo/common/hexec"
    31  	"github.com/neohugo/neohugo/common/loggers"
    32  	"github.com/neohugo/neohugo/common/text"
    33  	"github.com/neohugo/neohugo/hugofs"
    34  	"github.com/neohugo/neohugo/identity"
    35  
    36  	"github.com/neohugo/neohugo/common/neohugo"
    37  
    38  	"github.com/neohugo/neohugo/resources/internal"
    39  	"github.com/spf13/afero"
    40  	"github.com/spf13/cast"
    41  
    42  	"github.com/mitchellh/mapstructure"
    43  
    44  	"github.com/neohugo/neohugo/common/herrors"
    45  	"github.com/neohugo/neohugo/resources"
    46  	"github.com/neohugo/neohugo/resources/resource"
    47  )
    48  
    49  const importIdentifier = "@import"
    50  
    51  var (
    52  	cssSyntaxErrorRe = regexp.MustCompile(`> (\d+) \|`)
    53  	shouldImportRe   = regexp.MustCompile(`^@import ["'].*["'];?\s*(/\*.*\*/)?$`)
    54  )
    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]any) (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 map[string]any) (resource.Resource, error) {
    84  	return res.Transform(&postcssTransformation{rs: c.rs, optionsm: options})
    85  }
    86  
    87  // Some of the options from https://github.com/postcss/postcss-cli
    88  type Options struct {
    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  	// When InlineImports is enabled, we fail the build if an import cannot be resolved.
   103  	// You can enable this to allow the build to continue and leave the import statement in place.
   104  	// Note that the inline importer does not process url location or imports with media queries,
   105  	// so those will be left as-is even without enabling this option.
   106  	SkipInlineImportsNotFound bool
   107  
   108  	// Options for when not using a config file
   109  	Use         string // List of postcss plugins to use
   110  	Parser      string //  Custom postcss parser
   111  	Stringifier string // Custom postcss stringifier
   112  	Syntax      string // Custom postcss syntax
   113  }
   114  
   115  func (opts Options) toArgs() []string {
   116  	var args []string
   117  	if opts.NoMap {
   118  		args = append(args, "--no-map")
   119  	}
   120  	if opts.Use != "" {
   121  		args = append(args, "--use")
   122  		args = append(args, strings.Fields(opts.Use)...)
   123  	}
   124  	if opts.Parser != "" {
   125  		args = append(args, "--parser", opts.Parser)
   126  	}
   127  	if opts.Stringifier != "" {
   128  		args = append(args, "--stringifier", opts.Stringifier)
   129  	}
   130  	if opts.Syntax != "" {
   131  		args = append(args, "--syntax", opts.Syntax)
   132  	}
   133  	return args
   134  }
   135  
   136  type postcssTransformation struct {
   137  	optionsm map[string]any
   138  	rs       *resources.Spec
   139  }
   140  
   141  func (t *postcssTransformation) Key() internal.ResourceTransformationKey {
   142  	return internal.NewResourceTransformationKey("postcss", t.optionsm)
   143  }
   144  
   145  // Transform shells out to postcss-cli to do the heavy lifting.
   146  // For this to work, you need some additional tools. To install them globally:
   147  // npm install -g postcss-cli
   148  // npm install -g autoprefixer
   149  func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
   150  	const binaryName = "postcss"
   151  
   152  	infol := t.rs.Logger.InfoCommand(binaryName)
   153  	infow := loggers.LevelLoggerToWriter(infol)
   154  
   155  	ex := t.rs.ExecHelper
   156  
   157  	var configFile string
   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  		infol.Logf("use config file %q", 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  
   198  	stderr := io.MultiWriter(infow, &errBuf)
   199  	cmdArgs = append(cmdArgs, hexec.WithStderr(stderr))
   200  	cmdArgs = append(cmdArgs, hexec.WithStdout(ctx.To))
   201  	cmdArgs = append(cmdArgs, hexec.WithEnviron(neohugo.GetExecEnviron(t.rs.Cfg.BaseConfig().WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs)))
   202  
   203  	cmd, err := ex.Npx(binaryName, cmdArgs...)
   204  	if err != nil {
   205  		if hexec.IsNotFound(err) {
   206  			// This may be on a CI server etc. Will fall back to pre-built assets.
   207  			return &herrors.FeatureNotAvailableError{Cause: err}
   208  		}
   209  		return err
   210  	}
   211  
   212  	stdin, err := cmd.StdinPipe()
   213  	if err != nil {
   214  		return err
   215  	}
   216  
   217  	src := ctx.From
   218  
   219  	imp := newImportResolver(
   220  		ctx.From,
   221  		ctx.InPath,
   222  		options,
   223  		t.rs.Assets.Fs, t.rs.Logger, ctx.DependencyManager,
   224  	)
   225  
   226  	if options.InlineImports {
   227  		var err error
   228  		src, err = imp.resolve()
   229  		if err != nil {
   230  			return err
   231  		}
   232  	}
   233  
   234  	go func() {
   235  		defer stdin.Close()
   236  		// TODO may check error
   237  		//nolint
   238  		io.Copy(stdin, src)
   239  	}()
   240  
   241  	err = cmd.Run()
   242  	if err != nil {
   243  		if hexec.IsNotFound(err) {
   244  			return &herrors.FeatureNotAvailableError{
   245  				Cause: err,
   246  			}
   247  		}
   248  		return imp.toFileError(errBuf.String())
   249  	}
   250  
   251  	return nil
   252  }
   253  
   254  type fileOffset struct {
   255  	Filename string
   256  	Offset   int
   257  }
   258  
   259  type importResolver struct {
   260  	r      io.Reader
   261  	inPath string
   262  	opts   Options
   263  
   264  	contentSeen       map[string]bool
   265  	dependencyManager identity.Manager
   266  	linemap           map[int]fileOffset
   267  	fs                afero.Fs
   268  	logger            loggers.Logger
   269  }
   270  
   271  func newImportResolver(r io.Reader, inPath string, opts Options, fs afero.Fs, logger loggers.Logger, dependencyManager identity.Manager) *importResolver {
   272  	return &importResolver{
   273  		r:                 r,
   274  		dependencyManager: dependencyManager,
   275  		inPath:            inPath,
   276  		fs:                fs, logger: logger,
   277  		linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool),
   278  		opts: opts,
   279  	}
   280  }
   281  
   282  func (imp *importResolver) contentHash(filename string) ([]byte, string) {
   283  	b, err := afero.ReadFile(imp.fs, filename)
   284  	if err != nil {
   285  		return nil, ""
   286  	}
   287  	hash := sha256.Sum256(b)
   288  	return b, hex.EncodeToString(hash[:])
   289  }
   290  
   291  func (imp *importResolver) importRecursive(
   292  	lineNum int,
   293  	content string,
   294  	inPath string,
   295  ) (int, string, error) {
   296  	basePath := path.Dir(inPath)
   297  
   298  	var replacements []string
   299  	lines := strings.Split(content, "\n")
   300  
   301  	trackLine := func(i, offset int, line string) {
   302  		// TODO(bep) this is not very efficient.
   303  		imp.linemap[i+lineNum] = fileOffset{Filename: inPath, Offset: offset}
   304  	}
   305  
   306  	i := 0
   307  	for offset, line := range lines {
   308  		i++
   309  		lineTrimmed := strings.TrimSpace(line)
   310  		column := strings.Index(line, lineTrimmed)
   311  		line = lineTrimmed
   312  
   313  		if !imp.shouldImport(line) {
   314  			trackLine(i, offset, line)
   315  		} else {
   316  			path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';")
   317  			filename := filepath.Join(basePath, path)
   318  			imp.dependencyManager.AddIdentity(identity.CleanStringIdentity(filename))
   319  			importContent, hash := imp.contentHash(filename)
   320  
   321  			if importContent == nil {
   322  				if imp.opts.SkipInlineImportsNotFound {
   323  					trackLine(i, offset, line)
   324  					continue
   325  				}
   326  				pos := text.Position{
   327  					Filename:     inPath,
   328  					LineNumber:   offset + 1,
   329  					ColumnNumber: column + 1,
   330  				}
   331  				return 0, "", herrors.NewFileErrorFromFileInPos(fmt.Errorf("failed to resolve CSS @import \"%s\"", filename), pos, imp.fs, nil)
   332  			}
   333  
   334  			i--
   335  
   336  			if imp.contentSeen[hash] {
   337  				i++
   338  				// Just replace the line with an empty string.
   339  				replacements = append(replacements, []string{line, ""}...)
   340  				trackLine(i, offset, "IMPORT")
   341  				continue
   342  			}
   343  
   344  			imp.contentSeen[hash] = true
   345  
   346  			// Handle recursive imports.
   347  			l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename))
   348  			if err != nil {
   349  				return 0, "", err
   350  			}
   351  
   352  			trackLine(i, offset, line)
   353  
   354  			i += l
   355  
   356  			importContent = []byte(nested)
   357  
   358  			replacements = append(replacements, []string{line, string(importContent)}...)
   359  		}
   360  	}
   361  
   362  	if len(replacements) > 0 {
   363  		repl := strings.NewReplacer(replacements...)
   364  		content = repl.Replace(content)
   365  	}
   366  
   367  	return i, content, nil
   368  }
   369  
   370  func (imp *importResolver) resolve() (io.Reader, error) {
   371  	content, err := io.ReadAll(imp.r)
   372  	if err != nil {
   373  		return nil, err
   374  	}
   375  
   376  	contents := string(content)
   377  
   378  	_, newContent, err := imp.importRecursive(0, contents, imp.inPath)
   379  	if err != nil {
   380  		return nil, err
   381  	}
   382  
   383  	return strings.NewReader(newContent), nil
   384  }
   385  
   386  // See https://www.w3schools.com/cssref/pr_import_rule.asp
   387  // We currently only support simple file imports, no urls, no media queries.
   388  // So this is OK:
   389  //
   390  //	@import "navigation.css";
   391  //
   392  // This is not:
   393  //
   394  //	@import url("navigation.css");
   395  //	@import "mobstyle.css" screen and (max-width: 768px);
   396  func (imp *importResolver) shouldImport(s string) bool {
   397  	if !strings.HasPrefix(s, importIdentifier) {
   398  		return false
   399  	}
   400  	if strings.Contains(s, "url(") {
   401  		return false
   402  	}
   403  
   404  	return shouldImportRe.MatchString(s)
   405  }
   406  
   407  func (imp *importResolver) toFileError(output string) error {
   408  	inErr := errors.New(output)
   409  
   410  	match := cssSyntaxErrorRe.FindStringSubmatch(output)
   411  	if match == nil {
   412  		return inErr
   413  	}
   414  
   415  	lineNum, err := strconv.Atoi(match[1])
   416  	if err != nil {
   417  		return inErr
   418  	}
   419  
   420  	file, ok := imp.linemap[lineNum]
   421  	if !ok {
   422  		return inErr
   423  	}
   424  
   425  	fi, err := imp.fs.Stat(file.Filename)
   426  	if err != nil {
   427  		return inErr
   428  	}
   429  
   430  	meta := fi.(hugofs.FileMetaInfo).Meta()
   431  	realFilename := meta.Filename
   432  	f, err := meta.Open()
   433  	if err != nil {
   434  		return inErr
   435  	}
   436  	defer f.Close()
   437  
   438  	ferr := herrors.NewFileErrorFromName(inErr, realFilename)
   439  	pos := ferr.Position()
   440  	pos.LineNumber = file.Offset + 1
   441  	return ferr.UpdatePosition(pos).UpdateContent(f, nil)
   442  
   443  	// return herrors.NewFileErrorFromFile(inErr, file.Filename, realFilename, hugofs.Os, herrors.SimpleLineMatcher)
   444  }