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