Add Dart Sass support

But note that the Dart Sass Embedded Protocol is still in beta (beta 5), a main release scheduled for Q1 2021.

Fixes #7380
Fixes #8102
This commit is contained in:
Bjørn Erik Pedersen
2020-12-23 09:26:23 +01:00
parent f9f779786e
commit cea1574023
26 changed files with 804 additions and 207 deletions

View File

@@ -0,0 +1,115 @@
// Copyright 2020 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package godartsass integrates with the Dass Sass Embedded protocol to transpile
// SCSS/SASS.
package dartsass
import (
"io"
"github.com/gohugoio/hugo/helpers"
"github.com/gohugoio/hugo/hugolib/filesystems"
"github.com/gohugoio/hugo/resources"
"github.com/gohugoio/hugo/resources/resource"
"github.com/spf13/afero"
"github.com/bep/godartsass"
"github.com/mitchellh/mapstructure"
)
// used as part of the cache key.
const transformationName = "tocss-dart"
func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) (*Client, error) {
if !Supports() {
return &Client{dartSassNoAvailable: true}, nil
}
transpiler, err := godartsass.Start(godartsass.Options{})
if err != nil {
return nil, err
}
return &Client{sfs: fs, workFs: rs.BaseFs.Work, rs: rs, transpiler: transpiler}, nil
}
type Client struct {
dartSassNoAvailable bool
rs *resources.Spec
sfs *filesystems.SourceFilesystem
workFs afero.Fs
transpiler *godartsass.Transpiler
}
func (c *Client) ToCSS(res resources.ResourceTransformer, args map[string]interface{}) (resource.Resource, error) {
if c.dartSassNoAvailable {
return res.Transform(resources.NewFeatureNotAvailableTransformer(transformationName, args))
}
return res.Transform(&transform{c: c, optsm: args})
}
func (c *Client) Close() error {
if c.transpiler == nil {
return nil
}
return c.transpiler.Close()
}
func (c *Client) toCSS(args godartsass.Args, src io.Reader) (godartsass.Result, error) {
var res godartsass.Result
in := helpers.ReaderToString(src)
args.Source = in
res, err := c.transpiler.Execute(args)
if err != nil {
return res, err
}
return res, err
}
type Options struct {
// Hugo, will by default, just replace the extension of the source
// to .css, e.g. "scss/main.scss" becomes "scss/main.css". You can
// control this by setting this, e.g. "styles/main.css" will create
// a Resource with that as a base for RelPermalink etc.
TargetPath string
// Hugo automatically adds the entry directories (where the main.scss lives)
// for project and themes to the list of include paths sent to LibSASS.
// Any paths set in this setting will be appended. Note that these will be
// treated as relative to the working dir, i.e. no include paths outside the
// project/themes.
IncludePaths []string
// Default is nested.
// One of nested, expanded, compact, compressed.
OutputStyle string
// When enabled, Hugo will generate a source map.
EnableSourceMap bool
}
func decodeOptions(m map[string]interface{}) (opts Options, err error) {
if m == nil {
return
}
err = mapstructure.WeakDecode(m, &opts)
if opts.TargetPath != "" {
opts.TargetPath = helpers.ToSlashTrimLeading(opts.TargetPath)
}
return
}

View File

@@ -0,0 +1,222 @@
// Copyright 2020 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package dartsass
import (
"fmt"
"io"
"net/url"
"path"
"path/filepath"
"strings"
"github.com/cli/safeexec"
"github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/htesting"
"github.com/gohugoio/hugo/media"
"github.com/gohugoio/hugo/resources"
"github.com/gohugoio/hugo/resources/internal"
"github.com/spf13/afero"
"github.com/gohugoio/hugo/hugofs"
"github.com/bep/godartsass"
)
// See https://github.com/sass/dart-sass-embedded/issues/24
const stdinPlaceholder = "HUGOSTDIN"
// Supports returns whether dart-sass-embedded is found in $PATH.
func Supports() bool {
if htesting.SupportsAll() {
return true
}
p, err := safeexec.LookPath("dart-sass-embedded")
return err == nil && p != ""
}
type transform struct {
optsm map[string]interface{}
c *Client
}
func (t *transform) Key() internal.ResourceTransformationKey {
return internal.NewResourceTransformationKey(transformationName, t.optsm)
}
func (t *transform) Transform(ctx *resources.ResourceTransformationCtx) error {
ctx.OutMediaType = media.CSSType
opts, err := decodeOptions(t.optsm)
if err != nil {
return err
}
if opts.TargetPath != "" {
ctx.OutPath = opts.TargetPath
} else {
ctx.ReplaceOutPathExtension(".css")
}
baseDir := path.Dir(ctx.SourcePath)
args := godartsass.Args{
URL: stdinPlaceholder,
IncludePaths: t.c.sfs.RealDirs(baseDir),
ImportResolver: importResolver{
baseDir: baseDir,
c: t.c,
},
EnableSourceMap: opts.EnableSourceMap,
}
// Append any workDir relative include paths
for _, ip := range opts.IncludePaths {
info, err := t.c.workFs.Stat(filepath.Clean(ip))
if err == nil {
filename := info.(hugofs.FileMetaInfo).Meta().Filename()
args.IncludePaths = append(args.IncludePaths, filename)
}
}
if ctx.InMediaType.SubType == media.SASSType.SubType {
args.SourceSyntax = godartsass.SourceSyntaxSASS
}
res, err := t.c.toCSS(args, ctx.From)
if err != nil {
if sassErr, ok := err.(godartsass.SassError); ok {
start := sassErr.Span.Start
context := strings.TrimSpace(sassErr.Span.Context)
filename, _ := urlToFilename(sassErr.Span.Url)
if filename == stdinPlaceholder {
if ctx.SourcePath == "" {
return sassErr
}
filename = t.c.sfs.RealFilename(ctx.SourcePath)
}
offsetMatcher := func(m herrors.LineMatcher) bool {
return m.Offset+len(m.Line) >= start.Offset && strings.Contains(m.Line, context)
}
ferr, ok := herrors.WithFileContextForFile(
herrors.NewFileError("scss", -1, -1, start.Column, sassErr),
filename,
filename,
hugofs.Os,
offsetMatcher)
if !ok {
return sassErr
}
return ferr
}
return err
}
out := res.CSS
_, err = io.WriteString(ctx.To, out)
if err != nil {
return err
}
if opts.EnableSourceMap && res.SourceMap != "" {
if err := ctx.PublishSourceMap(res.SourceMap); err != nil {
return err
}
_, err = fmt.Fprintf(ctx.To, "\n\n/*# sourceMappingURL=%s */", path.Base(ctx.OutPath)+".map")
}
return err
}
type importResolver struct {
baseDir string
c *Client
}
func (t importResolver) CanonicalizeURL(url string) (string, error) {
filePath, isURL := urlToFilename(url)
var prevDir string
var pathDir string
if isURL {
var found bool
prevDir, found = t.c.sfs.MakePathRelative(filepath.Dir(filePath))
if !found {
// Not a member of this filesystem, let Dart Sass handle it.
return "", nil
}
} else {
prevDir = t.baseDir
pathDir = path.Dir(url)
}
basePath := filepath.Join(prevDir, pathDir)
name := filepath.Base(filePath)
// Pick the first match.
var namePatterns []string
if strings.Contains(name, ".") {
namePatterns = []string{"_%s", "%s"}
} else if strings.HasPrefix(name, "_") {
namePatterns = []string{"_%s.scss", "_%s.sass"}
} else {
namePatterns = []string{"_%s.scss", "%s.scss", "_%s.sass", "%s.sass"}
}
name = strings.TrimPrefix(name, "_")
for _, namePattern := range namePatterns {
filenameToCheck := filepath.Join(basePath, fmt.Sprintf(namePattern, name))
fi, err := t.c.sfs.Fs.Stat(filenameToCheck)
if err == nil {
if fim, ok := fi.(hugofs.FileMetaInfo); ok {
return "file://" + filepath.ToSlash(fim.Meta().Filename()), nil
}
}
}
// Not found, let Dart Dass handle it
return "", nil
}
func (t importResolver) Load(url string) (string, error) {
filename, _ := urlToFilename(url)
b, err := afero.ReadFile(hugofs.Os, filename)
return string(b), err
}
// TODO(bep) add tests
func urlToFilename(urls string) (string, bool) {
u, err := url.ParseRequestURI(urls)
if err != nil {
return filepath.FromSlash(urls), false
}
p := filepath.FromSlash(u.Path)
if u.Host != "" {
// C:\data\file.txt
p = strings.ToUpper(u.Host) + ":" + p
}
return p, true
}

View File

@@ -411,6 +411,9 @@ func (r *resourceAdapter) transform(publish, setContent bool) error {
errMsg = ". Check your PostCSS installation; install with \"npm install postcss-cli\". See https://gohugo.io/hugo-pipes/postcss/"
} else if tr.Key().Name == "tocss" {
errMsg = ". Check your Hugo installation; you need the extended version to build SCSS/SASS."
} else if tr.Key().Name == "tocss-dart" {
errMsg = ". You need dart-sass-embedded in your system $PATH."
} else if tr.Key().Name == "babel" {
errMsg = ". You need to install Babel, see https://gohugo.io/hugo-pipes/babel/"
}
@@ -442,6 +445,9 @@ func (r *resourceAdapter) transform(publish, setContent bool) error {
if tryFileCache {
f := r.target.tryTransformedFileCache(key, updates)
if f == nil {
if err != nil {
return newErr(err)
}
return newErr(errors.Errorf("resource %q not found in file cache", key))
}
transformedContentr = f