github.com/anakojm/hugo-katex@v0.0.0-20231023141351-42d6f5de9c0b/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/loggers" 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/resources/internal" 37 "github.com/spf13/afero" 38 "github.com/spf13/cast" 39 40 "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 ( 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 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 // When InlineImports is enabled, we fail the build if an import cannot be resolved. 104 // You can enable this to allow the build to continue and leave the import statement in place. 105 // Note that the inline importer does not process url location or imports with media queries, 106 // so those will be left as-is even without enabling this option. 107 SkipInlineImportsNotFound bool 108 109 // Options for when not using a config file 110 Use string // List of postcss plugins to use 111 Parser string // Custom postcss parser 112 Stringifier string // Custom postcss stringifier 113 Syntax string // Custom postcss syntax 114 } 115 116 func (opts Options) toArgs() []string { 117 var args []string 118 if opts.NoMap { 119 args = append(args, "--no-map") 120 } 121 if opts.Use != "" { 122 args = append(args, "--use") 123 args = append(args, strings.Fields(opts.Use)...) 124 } 125 if opts.Parser != "" { 126 args = append(args, "--parser", opts.Parser) 127 } 128 if opts.Stringifier != "" { 129 args = append(args, "--stringifier", opts.Stringifier) 130 } 131 if opts.Syntax != "" { 132 args = append(args, "--syntax", opts.Syntax) 133 } 134 return args 135 } 136 137 type postcssTransformation struct { 138 optionsm map[string]any 139 rs *resources.Spec 140 } 141 142 func (t *postcssTransformation) Key() internal.ResourceTransformationKey { 143 return internal.NewResourceTransformationKey("postcss", t.optionsm) 144 } 145 146 // Transform shells out to postcss-cli to do the heavy lifting. 147 // For this to work, you need some additional tools. To install them globally: 148 // npm install -g postcss-cli 149 // npm install -g autoprefixer 150 func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { 151 const binaryName = "postcss" 152 153 infol := t.rs.Logger.InfoCommand(binaryName) 154 infoW := loggers.LevelLoggerToWriter(infol) 155 156 ex := t.rs.ExecHelper 157 158 var configFile string 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 infol.Logf("use config file %q", 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 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.Cfg.BaseConfig().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.FeatureNotAvailableError{Cause: err} 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.FeatureNotAvailableError{ 244 Cause: err, 245 } 246 } 247 return imp.toFileError(errBuf.String()) 248 } 249 250 return nil 251 } 252 253 type fileOffset struct { 254 Filename string 255 Offset int 256 } 257 258 type importResolver struct { 259 r io.Reader 260 inPath string 261 opts Options 262 263 contentSeen map[string]bool 264 linemap map[int]fileOffset 265 fs afero.Fs 266 logger loggers.Logger 267 } 268 269 func newImportResolver(r io.Reader, inPath string, opts Options, fs afero.Fs, logger loggers.Logger) *importResolver { 270 return &importResolver{ 271 r: r, 272 inPath: inPath, 273 fs: fs, logger: logger, 274 linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool), 275 opts: opts, 276 } 277 } 278 279 func (imp *importResolver) contentHash(filename string) ([]byte, string) { 280 b, err := afero.ReadFile(imp.fs, filename) 281 if err != nil { 282 return nil, "" 283 } 284 h := sha256.New() 285 h.Write(b) 286 return b, hex.EncodeToString(h.Sum(nil)) 287 } 288 289 func (imp *importResolver) importRecursive( 290 lineNum int, 291 content string, 292 inPath string) (int, string, error) { 293 basePath := path.Dir(inPath) 294 295 var replacements []string 296 lines := strings.Split(content, "\n") 297 298 trackLine := func(i, offset int, line string) { 299 // TODO(bep) this is not very efficient. 300 imp.linemap[i+lineNum] = fileOffset{Filename: inPath, Offset: offset} 301 } 302 303 i := 0 304 for offset, line := range lines { 305 i++ 306 lineTrimmed := strings.TrimSpace(line) 307 column := strings.Index(line, lineTrimmed) 308 line = lineTrimmed 309 310 if !imp.shouldImport(line) { 311 trackLine(i, offset, line) 312 } else { 313 path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';") 314 filename := filepath.Join(basePath, path) 315 importContent, hash := imp.contentHash(filename) 316 317 if importContent == nil { 318 if imp.opts.SkipInlineImportsNotFound { 319 trackLine(i, offset, line) 320 continue 321 } 322 pos := text.Position{ 323 Filename: inPath, 324 LineNumber: offset + 1, 325 ColumnNumber: column + 1, 326 } 327 return 0, "", herrors.NewFileErrorFromFileInPos(fmt.Errorf("failed to resolve CSS @import \"%s\"", filename), pos, imp.fs, nil) 328 } 329 330 i-- 331 332 if imp.contentSeen[hash] { 333 i++ 334 // Just replace the line with an empty string. 335 replacements = append(replacements, []string{line, ""}...) 336 trackLine(i, offset, "IMPORT") 337 continue 338 } 339 340 imp.contentSeen[hash] = true 341 342 // Handle recursive imports. 343 l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename)) 344 if err != nil { 345 return 0, "", err 346 } 347 348 trackLine(i, offset, line) 349 350 i += l 351 352 importContent = []byte(nested) 353 354 replacements = append(replacements, []string{line, string(importContent)}...) 355 } 356 } 357 358 if len(replacements) > 0 { 359 repl := strings.NewReplacer(replacements...) 360 content = repl.Replace(content) 361 } 362 363 return i, content, nil 364 } 365 366 func (imp *importResolver) resolve() (io.Reader, error) { 367 const importIdentifier = "@import" 368 369 content, err := io.ReadAll(imp.r) 370 if err != nil { 371 return nil, err 372 } 373 374 contents := string(content) 375 376 _, newContent, err := imp.importRecursive(0, contents, imp.inPath) 377 if err != nil { 378 return nil, err 379 } 380 381 return strings.NewReader(newContent), nil 382 } 383 384 // See https://www.w3schools.com/cssref/pr_import_rule.asp 385 // We currently only support simple file imports, no urls, no media queries. 386 // So this is OK: 387 // 388 // @import "navigation.css"; 389 // 390 // This is not: 391 // 392 // @import url("navigation.css"); 393 // @import "mobstyle.css" screen and (max-width: 768px); 394 func (imp *importResolver) shouldImport(s string) bool { 395 if !strings.HasPrefix(s, importIdentifier) { 396 return false 397 } 398 if strings.Contains(s, "url(") { 399 return false 400 } 401 402 return shouldImportRe.MatchString(s) 403 } 404 405 func (imp *importResolver) toFileError(output string) error { 406 inErr := errors.New(output) 407 408 match := cssSyntaxErrorRe.FindStringSubmatch(output) 409 if match == nil { 410 return inErr 411 } 412 413 lineNum, err := strconv.Atoi(match[1]) 414 if err != nil { 415 return inErr 416 } 417 418 file, ok := imp.linemap[lineNum] 419 if !ok { 420 return inErr 421 } 422 423 fi, err := imp.fs.Stat(file.Filename) 424 if err != nil { 425 return inErr 426 } 427 428 meta := fi.(hugofs.FileMetaInfo).Meta() 429 realFilename := meta.Filename 430 f, err := meta.Open() 431 if err != nil { 432 return inErr 433 } 434 defer f.Close() 435 436 ferr := herrors.NewFileErrorFromName(inErr, realFilename) 437 pos := ferr.Position() 438 pos.LineNumber = file.Offset + 1 439 return ferr.UpdatePosition(pos).UpdateContent(f, nil) 440 441 //return herrors.NewFileErrorFromFile(inErr, file.Filename, realFilename, hugofs.Os, herrors.SimpleLineMatcher) 442 443 }