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