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 }