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 }