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