hugo

Fork of github.com/gohugoio/hugo with reverse pagination support

git clone git://git.shimmy1996.com/hugo.git

postcss.go (11086B)

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