github.com/anakojm/hugo-katex@v0.0.0-20231023141351-42d6f5de9c0b/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/loggers"
    31  	"github.com/gohugoio/hugo/common/text"
    32  	"github.com/gohugoio/hugo/hugofs"
    33  
    34  	"github.com/gohugoio/hugo/common/hugo"
    35  
    36  	"github.com/gohugoio/hugo/resources/internal"
    37  	"github.com/spf13/afero"
    38  	"github.com/spf13/cast"
    39  
    40  	"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 (
    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  
    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  	// When InlineImports is enabled, we fail the build if an import cannot be resolved.
   104  	// You can enable this to allow the build to continue and leave the import statement in place.
   105  	// Note that the inline importer does not process url location or imports with media queries,
   106  	// so those will be left as-is even without enabling this option.
   107  	SkipInlineImportsNotFound bool
   108  
   109  	// Options for when not using a config file
   110  	Use         string // List of postcss plugins to use
   111  	Parser      string //  Custom postcss parser
   112  	Stringifier string // Custom postcss stringifier
   113  	Syntax      string // Custom postcss syntax
   114  }
   115  
   116  func (opts Options) toArgs() []string {
   117  	var args []string
   118  	if opts.NoMap {
   119  		args = append(args, "--no-map")
   120  	}
   121  	if opts.Use != "" {
   122  		args = append(args, "--use")
   123  		args = append(args, strings.Fields(opts.Use)...)
   124  	}
   125  	if opts.Parser != "" {
   126  		args = append(args, "--parser", opts.Parser)
   127  	}
   128  	if opts.Stringifier != "" {
   129  		args = append(args, "--stringifier", opts.Stringifier)
   130  	}
   131  	if opts.Syntax != "" {
   132  		args = append(args, "--syntax", opts.Syntax)
   133  	}
   134  	return args
   135  }
   136  
   137  type postcssTransformation struct {
   138  	optionsm map[string]any
   139  	rs       *resources.Spec
   140  }
   141  
   142  func (t *postcssTransformation) Key() internal.ResourceTransformationKey {
   143  	return internal.NewResourceTransformationKey("postcss", t.optionsm)
   144  }
   145  
   146  // Transform shells out to postcss-cli to do the heavy lifting.
   147  // For this to work, you need some additional tools. To install them globally:
   148  // npm install -g postcss-cli
   149  // npm install -g autoprefixer
   150  func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
   151  	const binaryName = "postcss"
   152  
   153  	infol := t.rs.Logger.InfoCommand(binaryName)
   154  	infoW := loggers.LevelLoggerToWriter(infol)
   155  
   156  	ex := t.rs.ExecHelper
   157  
   158  	var configFile string
   159  
   160  	var options Options
   161  	if t.optionsm != nil {
   162  		var err error
   163  		options, err = decodeOptions(t.optionsm)
   164  		if err != nil {
   165  			return err
   166  		}
   167  	}
   168  
   169  	if options.Config != "" {
   170  		configFile = options.Config
   171  	} else {
   172  		configFile = "postcss.config.js"
   173  	}
   174  
   175  	configFile = filepath.Clean(configFile)
   176  
   177  	// We need an absolute filename to the config file.
   178  	if !filepath.IsAbs(configFile) {
   179  		configFile = t.rs.BaseFs.ResolveJSConfigFile(configFile)
   180  		if configFile == "" && options.Config != "" {
   181  			// Only fail if the user specified config file is not found.
   182  			return fmt.Errorf("postcss config %q not found:", options.Config)
   183  		}
   184  	}
   185  
   186  	var cmdArgs []any
   187  
   188  	if configFile != "" {
   189  		infol.Logf("use config file %q", configFile)
   190  		cmdArgs = []any{"--config", configFile}
   191  	}
   192  
   193  	if optArgs := options.toArgs(); len(optArgs) > 0 {
   194  		cmdArgs = append(cmdArgs, collections.StringSliceToInterfaceSlice(optArgs)...)
   195  	}
   196  
   197  	var errBuf bytes.Buffer
   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.Cfg.BaseConfig().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.FeatureNotAvailableError{Cause: err}
   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.FeatureNotAvailableError{
   244  				Cause: err,
   245  			}
   246  		}
   247  		return imp.toFileError(errBuf.String())
   248  	}
   249  
   250  	return nil
   251  }
   252  
   253  type fileOffset struct {
   254  	Filename string
   255  	Offset   int
   256  }
   257  
   258  type importResolver struct {
   259  	r      io.Reader
   260  	inPath string
   261  	opts   Options
   262  
   263  	contentSeen map[string]bool
   264  	linemap     map[int]fileOffset
   265  	fs          afero.Fs
   266  	logger      loggers.Logger
   267  }
   268  
   269  func newImportResolver(r io.Reader, inPath string, opts Options, fs afero.Fs, logger loggers.Logger) *importResolver {
   270  	return &importResolver{
   271  		r:      r,
   272  		inPath: inPath,
   273  		fs:     fs, logger: logger,
   274  		linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool),
   275  		opts: opts,
   276  	}
   277  }
   278  
   279  func (imp *importResolver) contentHash(filename string) ([]byte, string) {
   280  	b, err := afero.ReadFile(imp.fs, filename)
   281  	if err != nil {
   282  		return nil, ""
   283  	}
   284  	h := sha256.New()
   285  	h.Write(b)
   286  	return b, hex.EncodeToString(h.Sum(nil))
   287  }
   288  
   289  func (imp *importResolver) importRecursive(
   290  	lineNum int,
   291  	content string,
   292  	inPath string) (int, string, error) {
   293  	basePath := path.Dir(inPath)
   294  
   295  	var replacements []string
   296  	lines := strings.Split(content, "\n")
   297  
   298  	trackLine := func(i, offset int, line string) {
   299  		// TODO(bep) this is not very efficient.
   300  		imp.linemap[i+lineNum] = fileOffset{Filename: inPath, Offset: offset}
   301  	}
   302  
   303  	i := 0
   304  	for offset, line := range lines {
   305  		i++
   306  		lineTrimmed := strings.TrimSpace(line)
   307  		column := strings.Index(line, lineTrimmed)
   308  		line = lineTrimmed
   309  
   310  		if !imp.shouldImport(line) {
   311  			trackLine(i, offset, line)
   312  		} else {
   313  			path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';")
   314  			filename := filepath.Join(basePath, path)
   315  			importContent, hash := imp.contentHash(filename)
   316  
   317  			if importContent == nil {
   318  				if imp.opts.SkipInlineImportsNotFound {
   319  					trackLine(i, offset, line)
   320  					continue
   321  				}
   322  				pos := text.Position{
   323  					Filename:     inPath,
   324  					LineNumber:   offset + 1,
   325  					ColumnNumber: column + 1,
   326  				}
   327  				return 0, "", herrors.NewFileErrorFromFileInPos(fmt.Errorf("failed to resolve CSS @import \"%s\"", filename), pos, imp.fs, nil)
   328  			}
   329  
   330  			i--
   331  
   332  			if imp.contentSeen[hash] {
   333  				i++
   334  				// Just replace the line with an empty string.
   335  				replacements = append(replacements, []string{line, ""}...)
   336  				trackLine(i, offset, "IMPORT")
   337  				continue
   338  			}
   339  
   340  			imp.contentSeen[hash] = true
   341  
   342  			// Handle recursive imports.
   343  			l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename))
   344  			if err != nil {
   345  				return 0, "", err
   346  			}
   347  
   348  			trackLine(i, offset, line)
   349  
   350  			i += l
   351  
   352  			importContent = []byte(nested)
   353  
   354  			replacements = append(replacements, []string{line, string(importContent)}...)
   355  		}
   356  	}
   357  
   358  	if len(replacements) > 0 {
   359  		repl := strings.NewReplacer(replacements...)
   360  		content = repl.Replace(content)
   361  	}
   362  
   363  	return i, content, nil
   364  }
   365  
   366  func (imp *importResolver) resolve() (io.Reader, error) {
   367  	const importIdentifier = "@import"
   368  
   369  	content, err := io.ReadAll(imp.r)
   370  	if err != nil {
   371  		return nil, err
   372  	}
   373  
   374  	contents := string(content)
   375  
   376  	_, newContent, err := imp.importRecursive(0, contents, imp.inPath)
   377  	if err != nil {
   378  		return nil, err
   379  	}
   380  
   381  	return strings.NewReader(newContent), nil
   382  }
   383  
   384  // See https://www.w3schools.com/cssref/pr_import_rule.asp
   385  // We currently only support simple file imports, no urls, no media queries.
   386  // So this is OK:
   387  //
   388  //	@import "navigation.css";
   389  //
   390  // This is not:
   391  //
   392  //	@import url("navigation.css");
   393  //	@import "mobstyle.css" screen and (max-width: 768px);
   394  func (imp *importResolver) shouldImport(s string) bool {
   395  	if !strings.HasPrefix(s, importIdentifier) {
   396  		return false
   397  	}
   398  	if strings.Contains(s, "url(") {
   399  		return false
   400  	}
   401  
   402  	return shouldImportRe.MatchString(s)
   403  }
   404  
   405  func (imp *importResolver) toFileError(output string) error {
   406  	inErr := errors.New(output)
   407  
   408  	match := cssSyntaxErrorRe.FindStringSubmatch(output)
   409  	if match == nil {
   410  		return inErr
   411  	}
   412  
   413  	lineNum, err := strconv.Atoi(match[1])
   414  	if err != nil {
   415  		return inErr
   416  	}
   417  
   418  	file, ok := imp.linemap[lineNum]
   419  	if !ok {
   420  		return inErr
   421  	}
   422  
   423  	fi, err := imp.fs.Stat(file.Filename)
   424  	if err != nil {
   425  		return inErr
   426  	}
   427  
   428  	meta := fi.(hugofs.FileMetaInfo).Meta()
   429  	realFilename := meta.Filename
   430  	f, err := meta.Open()
   431  	if err != nil {
   432  		return inErr
   433  	}
   434  	defer f.Close()
   435  
   436  	ferr := herrors.NewFileErrorFromName(inErr, realFilename)
   437  	pos := ferr.Position()
   438  	pos.LineNumber = file.Offset + 1
   439  	return ferr.UpdatePosition(pos).UpdateContent(f, nil)
   440  
   441  	//return herrors.NewFileErrorFromFile(inErr, file.Filename, realFilename, hugofs.Os, herrors.SimpleLineMatcher)
   442  
   443  }