github.com/kovansky/hugo@v0.92.3-0.20220224232819-63076e4ff19f/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/gohugoio/hugo/common/collections" 29 "github.com/gohugoio/hugo/common/hexec" 30 31 "github.com/gohugoio/hugo/common/hugo" 32 33 "github.com/gohugoio/hugo/common/loggers" 34 35 "github.com/gohugoio/hugo/resources/internal" 36 "github.com/spf13/afero" 37 "github.com/spf13/cast" 38 39 "github.com/gohugoio/hugo/hugofs" 40 "github.com/pkg/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 cssSyntaxErrorRe = regexp.MustCompile(`> (\d+) \|`) 52 53 var shouldImportRe = regexp.MustCompile(`^@import ["'].*["'];?\s*(/\*.*\*/)?$`) 54 55 // New creates a new Client with the given specification. 56 func New(rs *resources.Spec) *Client { 57 return &Client{rs: rs} 58 } 59 60 func DecodeOptions(m map[string]interface{}) (opts Options, err error) { 61 if m == nil { 62 return 63 } 64 err = mapstructure.WeakDecode(m, &opts) 65 66 if !opts.NoMap { 67 // There was for a long time a discrepancy between documentation and 68 // implementation for the noMap property, so we need to support both 69 // camel and snake case. 70 opts.NoMap = cast.ToBool(m["no-map"]) 71 } 72 73 return 74 } 75 76 // Client is the client used to do PostCSS transformations. 77 type Client struct { 78 rs *resources.Spec 79 } 80 81 // Process transforms the given Resource with the PostCSS processor. 82 func (c *Client) Process(res resources.ResourceTransformer, options Options) (resource.Resource, error) { 83 return res.Transform(&postcssTransformation{rs: c.rs, options: options}) 84 } 85 86 // Some of the options from https://github.com/postcss/postcss-cli 87 type Options struct { 88 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 // Options for when not using a config file 103 Use string // List of postcss plugins to use 104 Parser string // Custom postcss parser 105 Stringifier string // Custom postcss stringifier 106 Syntax string // Custom postcss syntax 107 } 108 109 func (opts Options) toArgs() []string { 110 var args []string 111 if opts.NoMap { 112 args = append(args, "--no-map") 113 } 114 if opts.Use != "" { 115 args = append(args, "--use") 116 args = append(args, strings.Fields(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 binaryName = "postcss" 145 146 ex := t.rs.ExecHelper 147 148 var configFile string 149 logger := t.rs.Logger 150 151 if t.options.Config != "" { 152 configFile = t.options.Config 153 } else { 154 configFile = "postcss.config.js" 155 } 156 157 configFile = filepath.Clean(configFile) 158 159 // We need an absolute filename to the config file. 160 if !filepath.IsAbs(configFile) { 161 configFile = t.rs.BaseFs.ResolveJSConfigFile(configFile) 162 if configFile == "" && t.options.Config != "" { 163 // Only fail if the user specified config file is not found. 164 return errors.Errorf("postcss config %q not found:", configFile) 165 } 166 } 167 168 var cmdArgs []interface{} 169 170 if configFile != "" { 171 logger.Infoln("postcss: use config file", configFile) 172 cmdArgs = []interface{}{"--config", configFile} 173 } 174 175 if optArgs := t.options.toArgs(); len(optArgs) > 0 { 176 cmdArgs = append(cmdArgs, collections.StringSliceToInterfaceSlice(optArgs)...) 177 } 178 179 var errBuf bytes.Buffer 180 infoW := loggers.LoggerToWriterWithPrefix(logger.Info(), "postcss") 181 182 stderr := io.MultiWriter(infoW, &errBuf) 183 cmdArgs = append(cmdArgs, hexec.WithStderr(stderr)) 184 cmdArgs = append(cmdArgs, hexec.WithStdout(ctx.To)) 185 cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(t.rs.WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs))) 186 187 cmd, err := ex.Npx(binaryName, cmdArgs...) 188 if err != nil { 189 if hexec.IsNotFound(err) { 190 // This may be on a CI server etc. Will fall back to pre-built assets. 191 return herrors.ErrFeatureNotAvailable 192 } 193 return err 194 } 195 196 stdin, err := cmd.StdinPipe() 197 if err != nil { 198 return err 199 } 200 201 src := ctx.From 202 203 imp := newImportResolver( 204 ctx.From, 205 ctx.InPath, 206 t.rs.Assets.Fs, t.rs.Logger, 207 ) 208 209 if t.options.InlineImports { 210 var err error 211 src, err = imp.resolve() 212 if err != nil { 213 return err 214 } 215 } 216 217 go func() { 218 defer stdin.Close() 219 io.Copy(stdin, src) 220 }() 221 222 err = cmd.Run() 223 if err != nil { 224 if hexec.IsNotFound(err) { 225 return herrors.ErrFeatureNotAvailable 226 } 227 return imp.toFileError(errBuf.String()) 228 } 229 230 return nil 231 } 232 233 type fileOffset struct { 234 Filename string 235 Offset int 236 } 237 238 type importResolver struct { 239 r io.Reader 240 inPath string 241 242 contentSeen map[string]bool 243 linemap map[int]fileOffset 244 fs afero.Fs 245 logger loggers.Logger 246 } 247 248 func newImportResolver(r io.Reader, inPath string, fs afero.Fs, logger loggers.Logger) *importResolver { 249 return &importResolver{ 250 r: r, 251 inPath: inPath, 252 fs: fs, logger: logger, 253 linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool), 254 } 255 } 256 257 func (imp *importResolver) contentHash(filename string) ([]byte, string) { 258 b, err := afero.ReadFile(imp.fs, filename) 259 if err != nil { 260 return nil, "" 261 } 262 h := sha256.New() 263 h.Write(b) 264 return b, hex.EncodeToString(h.Sum(nil)) 265 } 266 267 func (imp *importResolver) importRecursive( 268 lineNum int, 269 content string, 270 inPath string) (int, string, error) { 271 basePath := path.Dir(inPath) 272 273 var replacements []string 274 lines := strings.Split(content, "\n") 275 276 trackLine := func(i, offset int, line string) { 277 // TODO(bep) this is not very efficient. 278 imp.linemap[i+lineNum] = fileOffset{Filename: inPath, Offset: offset} 279 } 280 281 i := 0 282 for offset, line := range lines { 283 i++ 284 line = strings.TrimSpace(line) 285 286 if !imp.shouldImport(line) { 287 trackLine(i, offset, line) 288 } else { 289 i-- 290 path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';") 291 filename := filepath.Join(basePath, path) 292 importContent, hash := imp.contentHash(filename) 293 if importContent == nil { 294 trackLine(i, offset, "ERROR") 295 imp.logger.Warnf("postcss: Failed to resolve CSS @import in %q for path %q", inPath, filename) 296 continue 297 } 298 299 if imp.contentSeen[hash] { 300 i++ 301 // Just replace the line with an empty string. 302 replacements = append(replacements, []string{line, ""}...) 303 trackLine(i, offset, "IMPORT") 304 continue 305 } 306 307 imp.contentSeen[hash] = true 308 309 // Handle recursive imports. 310 l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename)) 311 if err != nil { 312 return 0, "", err 313 } 314 315 trackLine(i, offset, line) 316 317 i += l 318 319 importContent = []byte(nested) 320 321 replacements = append(replacements, []string{line, string(importContent)}...) 322 } 323 } 324 325 if len(replacements) > 0 { 326 repl := strings.NewReplacer(replacements...) 327 content = repl.Replace(content) 328 } 329 330 return i, content, nil 331 } 332 333 func (imp *importResolver) resolve() (io.Reader, error) { 334 const importIdentifier = "@import" 335 336 content, err := ioutil.ReadAll(imp.r) 337 if err != nil { 338 return nil, err 339 } 340 341 contents := string(content) 342 343 _, newContent, err := imp.importRecursive(0, contents, imp.inPath) 344 if err != nil { 345 return nil, err 346 } 347 348 return strings.NewReader(newContent), nil 349 } 350 351 // See https://www.w3schools.com/cssref/pr_import_rule.asp 352 // We currently only support simple file imports, no urls, no media queries. 353 // So this is OK: 354 // @import "navigation.css"; 355 // This is not: 356 // @import url("navigation.css"); 357 // @import "mobstyle.css" screen and (max-width: 768px); 358 func (imp *importResolver) shouldImport(s string) bool { 359 if !strings.HasPrefix(s, importIdentifier) { 360 return false 361 } 362 if strings.Contains(s, "url(") { 363 return false 364 } 365 366 return shouldImportRe.MatchString(s) 367 } 368 369 func (imp *importResolver) toFileError(output string) error { 370 inErr := errors.New(strings.TrimSpace(output)) 371 372 match := cssSyntaxErrorRe.FindStringSubmatch(output) 373 if match == nil { 374 return inErr 375 } 376 377 lineNum, err := strconv.Atoi(match[1]) 378 if err != nil { 379 return inErr 380 } 381 382 file, ok := imp.linemap[lineNum] 383 if !ok { 384 return inErr 385 } 386 387 fi, err := imp.fs.Stat(file.Filename) 388 if err != nil { 389 return inErr 390 } 391 realFilename := fi.(hugofs.FileMetaInfo).Meta().Filename 392 393 ferr := herrors.NewFileError("css", -1, file.Offset+1, 1, inErr) 394 395 werr, ok := herrors.WithFileContextForFile(ferr, realFilename, file.Filename, imp.fs, herrors.SimpleLineMatcher) 396 397 if !ok { 398 return ferr 399 } 400 401 return werr 402 }