github.com/graemephi/kahugo@v0.62.3-0.20211121071557-d78c0423784d/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/cli/safeexec" 29 30 "github.com/gohugoio/hugo/common/hexec" 31 32 "github.com/gohugoio/hugo/common/hugo" 33 34 "github.com/gohugoio/hugo/common/loggers" 35 36 "github.com/gohugoio/hugo/resources/internal" 37 "github.com/spf13/afero" 38 "github.com/spf13/cast" 39 40 "github.com/gohugoio/hugo/hugofs" 41 "github.com/pkg/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 cssSyntaxErrorRe = regexp.MustCompile(`> (\d+) \|`) 53 54 var shouldImportRe = regexp.MustCompile(`^@import ["'].*["'];?\s*(/\*.*\*/)?$`) 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]interface{}) (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 Options) (resource.Resource, error) { 84 return res.Transform(&postcssTransformation{rs: c.rs, options: 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 // Options for when not using a config file 104 Use string // List of postcss plugins to use 105 Parser string // Custom postcss parser 106 Stringifier string // Custom postcss stringifier 107 Syntax string // Custom postcss syntax 108 } 109 110 func (opts Options) toArgs() []string { 111 var args []string 112 if opts.NoMap { 113 args = append(args, "--no-map") 114 } 115 if opts.Use != "" { 116 args = append(args, "--use") 117 args = append(args, strings.Fields(opts.Use)...) 118 } 119 if opts.Parser != "" { 120 args = append(args, "--parser", opts.Parser) 121 } 122 if opts.Stringifier != "" { 123 args = append(args, "--stringifier", opts.Stringifier) 124 } 125 if opts.Syntax != "" { 126 args = append(args, "--syntax", opts.Syntax) 127 } 128 return args 129 } 130 131 type postcssTransformation struct { 132 options Options 133 rs *resources.Spec 134 } 135 136 func (t *postcssTransformation) Key() internal.ResourceTransformationKey { 137 return internal.NewResourceTransformationKey("postcss", t.options) 138 } 139 140 // Transform shells out to postcss-cli to do the heavy lifting. 141 // For this to work, you need some additional tools. To install them globally: 142 // npm install -g postcss-cli 143 // npm install -g autoprefixer 144 func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { 145 const localPostCSSPath = "node_modules/.bin/" 146 const binaryName = "postcss" 147 148 // Try first in the project's node_modules. 149 csiBinPath := filepath.Join(t.rs.WorkingDir, localPostCSSPath, binaryName) 150 151 binary := csiBinPath 152 153 if _, err := safeexec.LookPath(binary); err != nil { 154 // Try PATH 155 binary = binaryName 156 if _, err := safeexec.LookPath(binary); err != nil { 157 // This may be on a CI server etc. Will fall back to pre-built assets. 158 return herrors.ErrFeatureNotAvailable 159 } 160 } 161 162 var configFile string 163 logger := t.rs.Logger 164 165 if t.options.Config != "" { 166 configFile = t.options.Config 167 } else { 168 configFile = "postcss.config.js" 169 } 170 171 configFile = filepath.Clean(configFile) 172 173 // We need an absolute filename to the config file. 174 if !filepath.IsAbs(configFile) { 175 configFile = t.rs.BaseFs.ResolveJSConfigFile(configFile) 176 if configFile == "" && t.options.Config != "" { 177 // Only fail if the user specified config file is not found. 178 return errors.Errorf("postcss config %q not found:", configFile) 179 } 180 } 181 182 var cmdArgs []string 183 184 if configFile != "" { 185 logger.Infoln("postcss: use config file", configFile) 186 cmdArgs = []string{"--config", configFile} 187 } 188 189 if optArgs := t.options.toArgs(); len(optArgs) > 0 { 190 cmdArgs = append(cmdArgs, optArgs...) 191 } 192 193 cmd, err := hexec.SafeCommand(binary, cmdArgs...) 194 if err != nil { 195 return err 196 } 197 198 var errBuf bytes.Buffer 199 infoW := loggers.LoggerToWriterWithPrefix(logger.Info(), "postcss") 200 201 cmd.Stdout = ctx.To 202 cmd.Stderr = io.MultiWriter(infoW, &errBuf) 203 204 cmd.Env = hugo.GetExecEnviron(t.rs.WorkingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs) 205 206 stdin, err := cmd.StdinPipe() 207 if err != nil { 208 return err 209 } 210 211 src := ctx.From 212 213 imp := newImportResolver( 214 ctx.From, 215 ctx.InPath, 216 t.rs.Assets.Fs, t.rs.Logger, 217 ) 218 219 if t.options.InlineImports { 220 var err error 221 src, err = imp.resolve() 222 if err != nil { 223 return err 224 } 225 } 226 227 go func() { 228 defer stdin.Close() 229 io.Copy(stdin, src) 230 }() 231 232 err = cmd.Run() 233 if err != nil { 234 return imp.toFileError(errBuf.String()) 235 } 236 237 return nil 238 } 239 240 type fileOffset struct { 241 Filename string 242 Offset int 243 } 244 245 type importResolver struct { 246 r io.Reader 247 inPath string 248 249 contentSeen map[string]bool 250 linemap map[int]fileOffset 251 fs afero.Fs 252 logger loggers.Logger 253 } 254 255 func newImportResolver(r io.Reader, inPath string, fs afero.Fs, logger loggers.Logger) *importResolver { 256 return &importResolver{ 257 r: r, 258 inPath: inPath, 259 fs: fs, logger: logger, 260 linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool), 261 } 262 } 263 264 func (imp *importResolver) contentHash(filename string) ([]byte, string) { 265 b, err := afero.ReadFile(imp.fs, filename) 266 if err != nil { 267 return nil, "" 268 } 269 h := sha256.New() 270 h.Write(b) 271 return b, hex.EncodeToString(h.Sum(nil)) 272 } 273 274 func (imp *importResolver) importRecursive( 275 lineNum int, 276 content string, 277 inPath string) (int, string, error) { 278 basePath := path.Dir(inPath) 279 280 var replacements []string 281 lines := strings.Split(content, "\n") 282 283 trackLine := func(i, offset int, line string) { 284 // TODO(bep) this is not very efficient. 285 imp.linemap[i+lineNum] = fileOffset{Filename: inPath, Offset: offset} 286 } 287 288 i := 0 289 for offset, line := range lines { 290 i++ 291 line = strings.TrimSpace(line) 292 293 if !imp.shouldImport(line) { 294 trackLine(i, offset, line) 295 } else { 296 i-- 297 path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';") 298 filename := filepath.Join(basePath, path) 299 importContent, hash := imp.contentHash(filename) 300 if importContent == nil { 301 trackLine(i, offset, "ERROR") 302 imp.logger.Warnf("postcss: Failed to resolve CSS @import in %q for path %q", inPath, filename) 303 continue 304 } 305 306 if imp.contentSeen[hash] { 307 i++ 308 // Just replace the line with an empty string. 309 replacements = append(replacements, []string{line, ""}...) 310 trackLine(i, offset, "IMPORT") 311 continue 312 } 313 314 imp.contentSeen[hash] = true 315 316 // Handle recursive imports. 317 l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename)) 318 if err != nil { 319 return 0, "", err 320 } 321 322 trackLine(i, offset, line) 323 324 i += l 325 326 importContent = []byte(nested) 327 328 replacements = append(replacements, []string{line, string(importContent)}...) 329 } 330 } 331 332 if len(replacements) > 0 { 333 repl := strings.NewReplacer(replacements...) 334 content = repl.Replace(content) 335 } 336 337 return i, content, nil 338 } 339 340 func (imp *importResolver) resolve() (io.Reader, error) { 341 const importIdentifier = "@import" 342 343 content, err := ioutil.ReadAll(imp.r) 344 if err != nil { 345 return nil, err 346 } 347 348 contents := string(content) 349 350 _, newContent, err := imp.importRecursive(0, contents, imp.inPath) 351 if err != nil { 352 return nil, err 353 } 354 355 return strings.NewReader(newContent), nil 356 } 357 358 // See https://www.w3schools.com/cssref/pr_import_rule.asp 359 // We currently only support simple file imports, no urls, no media queries. 360 // So this is OK: 361 // @import "navigation.css"; 362 // This is not: 363 // @import url("navigation.css"); 364 // @import "mobstyle.css" screen and (max-width: 768px); 365 func (imp *importResolver) shouldImport(s string) bool { 366 if !strings.HasPrefix(s, importIdentifier) { 367 return false 368 } 369 if strings.Contains(s, "url(") { 370 return false 371 } 372 373 return shouldImportRe.MatchString(s) 374 } 375 376 func (imp *importResolver) toFileError(output string) error { 377 inErr := errors.New(strings.TrimSpace(output)) 378 379 match := cssSyntaxErrorRe.FindStringSubmatch(output) 380 if match == nil { 381 return inErr 382 } 383 384 lineNum, err := strconv.Atoi(match[1]) 385 if err != nil { 386 return inErr 387 } 388 389 file, ok := imp.linemap[lineNum] 390 if !ok { 391 return inErr 392 } 393 394 fi, err := imp.fs.Stat(file.Filename) 395 if err != nil { 396 return inErr 397 } 398 realFilename := fi.(hugofs.FileMetaInfo).Meta().Filename 399 400 ferr := herrors.NewFileError("css", -1, file.Offset+1, 1, inErr) 401 402 werr, ok := herrors.WithFileContextForFile(ferr, realFilename, file.Filename, imp.fs, herrors.SimpleLineMatcher) 403 404 if !ok { 405 return ferr 406 } 407 408 return werr 409 }