mirror of
https://github.com/gohugoio/hugo.git
synced 2026-08-24 15:28:54 +00:00
@@ -106,6 +106,13 @@ func TestOptOsFs() TestOpt {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOptWithNpmInstall will enable npm install in integration tests.
|
||||
func TestOptWithNpmInstall() TestOpt {
|
||||
return func(c *IntegrationTestConfig) {
|
||||
c.NeedsNpmInstall = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestOptWithNFDOnDarwin will normalize the Unicode filenames to NFD on Darwin.
|
||||
func TestOptWithNFDOnDarwin() TestOpt {
|
||||
return func(c *IntegrationTestConfig) {
|
||||
|
||||
@@ -54,7 +54,6 @@ const (
|
||||
NsBatch = "_hugo-js-batch"
|
||||
|
||||
propsKeyImportContext = "importContext"
|
||||
propsResoure = "resource"
|
||||
)
|
||||
|
||||
//go:embed batch-esm-runner.gotmpl
|
||||
@@ -72,7 +71,7 @@ var (
|
||||
func NewBatcherClient(deps *deps.Deps) (js.BatcherClient, error) {
|
||||
c := &BatcherClient{
|
||||
d: deps,
|
||||
buildClient: NewBuildClient(deps.BaseFs.Assets, deps.ResourceSpec),
|
||||
buildClient: NewBuildClient(deps.BaseFs.Assets, deps.ResourceSpec, false),
|
||||
createClient: create.New(deps.ResourceSpec),
|
||||
batcherStore: hmaps.NewCache[string, js.Batcher](),
|
||||
bundlesStore: hmaps.NewCache[string, js.BatchPackage](),
|
||||
|
||||
@@ -32,17 +32,19 @@ import (
|
||||
)
|
||||
|
||||
// NewBuildClient creates a new BuildClient.
|
||||
func NewBuildClient(fs *filesystems.SourceFilesystem, rs *resources.Spec) *BuildClient {
|
||||
func NewBuildClient(fs *filesystems.SourceFilesystem, rs *resources.Spec, cssMode bool) *BuildClient {
|
||||
return &BuildClient{
|
||||
rs: rs,
|
||||
sfs: fs,
|
||||
rs: rs,
|
||||
sfs: fs,
|
||||
CssMode: cssMode,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildClient is a client for building JavaScript resources using esbuild.
|
||||
type BuildClient struct {
|
||||
rs *resources.Spec
|
||||
sfs *filesystems.SourceFilesystem
|
||||
rs *resources.Spec
|
||||
sfs *filesystems.SourceFilesystem
|
||||
CssMode bool
|
||||
}
|
||||
|
||||
// Build builds the given JavaScript resources using esbuild with the given options.
|
||||
@@ -80,7 +82,7 @@ func (c *BuildClient) Build(opts Options) (api.BuildResult, error) {
|
||||
return api.BuildResult{}, fmt.Errorf("inject: absolute paths not supported, must be relative to /assets")
|
||||
}
|
||||
|
||||
m := assetsResolver.resolveComponent(impPath)
|
||||
m := assetsResolver.resolveComponent(impPath, false)
|
||||
|
||||
if m == nil {
|
||||
return api.BuildResult{}, fmt.Errorf("inject: file %q not found", ext)
|
||||
@@ -190,7 +192,7 @@ func (c *BuildClient) Build(opts Options) (api.BuildResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if m := assetsResolver.resolveComponent(s); m != nil {
|
||||
if m := assetsResolver.resolveComponent(s, false); m != nil {
|
||||
return m.Filename
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gohugoio/hugo/common/hmaps"
|
||||
@@ -48,6 +49,22 @@ var (
|
||||
"es2024": api.ES2024,
|
||||
}
|
||||
|
||||
engineName = map[string]api.EngineName{
|
||||
"chrome": api.EngineChrome,
|
||||
"deno": api.EngineDeno,
|
||||
"edge": api.EngineEdge,
|
||||
"firefox": api.EngineFirefox,
|
||||
"hermes": api.EngineHermes,
|
||||
"ie": api.EngineIE,
|
||||
"ios": api.EngineIOS,
|
||||
"node": api.EngineNode,
|
||||
"opera": api.EngineOpera,
|
||||
"rhino": api.EngineRhino,
|
||||
"safari": api.EngineSafari,
|
||||
}
|
||||
|
||||
engineKeysSorted []string
|
||||
|
||||
// source names: https://github.com/evanw/esbuild/blob/9eca46464ed5615cb36a3beb3f7a7b9a8ffbe7cf/internal/config/config.go#L208
|
||||
nameLoader = map[string]api.Loader{
|
||||
"none": api.LoaderNone,
|
||||
@@ -70,6 +87,16 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
for k := range engineName {
|
||||
engineKeysSorted = append(engineKeysSorted, k)
|
||||
}
|
||||
// Sort by length descending to match longest prefix first.
|
||||
sort.Slice(engineKeysSorted, func(i, j int) bool {
|
||||
return len(engineKeysSorted[i]) > len(engineKeysSorted[j])
|
||||
})
|
||||
}
|
||||
|
||||
// DecodeExternalOptions decodes the given map into ExternalOptions.
|
||||
func DecodeExternalOptions(m map[string]any) (ExternalOptions, error) {
|
||||
opts := ExternalOptions{
|
||||
@@ -84,7 +111,9 @@ func DecodeExternalOptions(m map[string]any) (ExternalOptions, error) {
|
||||
opts.TargetPath = paths.ToSlashTrimLeading(opts.TargetPath)
|
||||
}
|
||||
|
||||
opts.Target = strings.ToLower(opts.Target)
|
||||
for i, t := range opts.Target {
|
||||
opts.Target[i] = strings.ToLower(t)
|
||||
}
|
||||
opts.Format = strings.ToLower(opts.Format)
|
||||
|
||||
return opts, nil
|
||||
@@ -112,10 +141,11 @@ type ExternalOptions struct {
|
||||
|
||||
SourcesContent bool
|
||||
|
||||
// The language target.
|
||||
// One of: es2015, es2016, es2017, es2018, es2019, es2020 or esnext.
|
||||
// Default is esnext.
|
||||
Target string
|
||||
// This sets the target environment for the generated JavaScript and/or CSS code.
|
||||
// It can specify the JavaScript language version to target (es2015, es2016, es2017, es2018, es2019, es2020 or esnext; default is esnext),
|
||||
// in addition to a set of environments to support, as slice of environment name followed by a version number. e.g. "chrome80", "firefox73", "safari13" or "edge80".
|
||||
// See https://esbuild.github.io/api/#target
|
||||
Target []string
|
||||
|
||||
// The output format.
|
||||
// One of: iife, cjs, esm
|
||||
@@ -127,6 +157,12 @@ type ExternalOptions struct {
|
||||
// See https://esbuild.github.io/api/#platform
|
||||
Platform string
|
||||
|
||||
// When you import a package in node, the main field in that package's package.json file
|
||||
// determines which file is imported (along with a lot of other rules).
|
||||
// The default main fields is controlled by ESBuild and depend on the current platform setting.
|
||||
// See https://esbuild.github.io/api/#main-fields
|
||||
MainFields []string
|
||||
|
||||
// External dependencies, e.g. "react".
|
||||
Externals []string
|
||||
|
||||
@@ -200,6 +236,7 @@ type InternalOptions struct {
|
||||
|
||||
Stdin bool // Set to true to pass in the entry point as a byte slice.
|
||||
Splitting bool
|
||||
IsCSS bool // Entry point is CSS.
|
||||
TsConfig string
|
||||
EntryPoints []string
|
||||
ImportOnResolveFunc func(string, api.OnResolveArgs) string
|
||||
@@ -218,10 +255,37 @@ type Options struct {
|
||||
}
|
||||
|
||||
func (opts *Options) compile() (err error) {
|
||||
target, found := nameTarget[opts.Target]
|
||||
if !found {
|
||||
err = fmt.Errorf("invalid target: %q", opts.Target)
|
||||
return
|
||||
var target api.Target
|
||||
for _, value := range opts.Target {
|
||||
if v, found := nameTarget[value]; found {
|
||||
if v > target {
|
||||
target = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var engines []api.Engine
|
||||
|
||||
OUTER:
|
||||
for _, value := range opts.Target {
|
||||
for _, engine := range engineKeysSorted {
|
||||
if strings.HasPrefix(value, engine) {
|
||||
version := value[len(engine):]
|
||||
if version == "" {
|
||||
return fmt.Errorf("invalid engine version: %q", value)
|
||||
}
|
||||
engines = append(engines, api.Engine{Name: engineName[engine], Version: version})
|
||||
continue OUTER
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if target == 0 && len(engines) == 0 && len(opts.Target) > 0 {
|
||||
return fmt.Errorf("unsupported target: %v", opts.Target)
|
||||
}
|
||||
|
||||
if target == 0 {
|
||||
target = api.ESNext
|
||||
}
|
||||
|
||||
var loaders map[string]api.Loader
|
||||
@@ -252,6 +316,8 @@ func (opts *Options) compile() (err error) {
|
||||
loader = api.LoaderTSX
|
||||
case media.Builtin.JSXType.SubType:
|
||||
loader = api.LoaderJSX
|
||||
case media.Builtin.CSSType.SubType:
|
||||
loader = api.LoaderCSS
|
||||
default:
|
||||
err = fmt.Errorf("unsupported Media Type: %q", opts.MediaType)
|
||||
return
|
||||
@@ -343,7 +409,9 @@ func (opts *Options) compile() (err error) {
|
||||
AbsWorkingDir: opts.AbsWorkingDir,
|
||||
|
||||
Target: target,
|
||||
Engines: engines,
|
||||
Format: format,
|
||||
MainFields: opts.MainFields,
|
||||
Platform: platform,
|
||||
Sourcemap: sourceMap,
|
||||
SourcesContent: sourcesContent,
|
||||
@@ -390,10 +458,20 @@ func (o Options) loaderFromFilename(filename string) api.Loader {
|
||||
return l
|
||||
}
|
||||
}
|
||||
l, found := extensionToLoaderMap[ext]
|
||||
if found {
|
||||
return l
|
||||
if o.IsCSS {
|
||||
l, found := extensionToLoaderMapCSS[ext]
|
||||
if found {
|
||||
return l
|
||||
}
|
||||
// For CSS builds, handling, default to the file loader for unknown extensions.
|
||||
return api.LoaderFile
|
||||
} else {
|
||||
l, found := extensionToLoaderMapJS[ext]
|
||||
if found {
|
||||
return l
|
||||
}
|
||||
}
|
||||
|
||||
return api.LoaderJS
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestToBuildOptions(t *testing.T) {
|
||||
|
||||
opts = Options{
|
||||
ExternalOptions: ExternalOptions{
|
||||
Target: "es2018",
|
||||
Target: []string{"es2018"},
|
||||
Format: "cjs",
|
||||
Minify: true,
|
||||
AvoidTDZ: true,
|
||||
@@ -75,7 +75,7 @@ func TestToBuildOptions(t *testing.T) {
|
||||
|
||||
opts = Options{
|
||||
ExternalOptions: ExternalOptions{
|
||||
Target: "es2018", Format: "cjs", Minify: true,
|
||||
Target: []string{"es2018"}, Format: "cjs", Minify: true,
|
||||
SourceMap: "inline",
|
||||
},
|
||||
InternalOptions: InternalOptions{
|
||||
@@ -102,7 +102,7 @@ func TestToBuildOptions(t *testing.T) {
|
||||
|
||||
opts = Options{
|
||||
ExternalOptions: ExternalOptions{
|
||||
Target: "es2018", Format: "cjs", Minify: true,
|
||||
Target: []string{"es2018"}, Format: "cjs", Minify: true,
|
||||
SourceMap: "inline",
|
||||
},
|
||||
InternalOptions: InternalOptions{
|
||||
@@ -129,7 +129,7 @@ func TestToBuildOptions(t *testing.T) {
|
||||
|
||||
opts = Options{
|
||||
ExternalOptions: ExternalOptions{
|
||||
Target: "es2018", Format: "cjs", Minify: true,
|
||||
Target: []string{"es2018"}, Format: "cjs", Minify: true,
|
||||
SourceMap: "external",
|
||||
},
|
||||
InternalOptions: InternalOptions{
|
||||
@@ -223,7 +223,7 @@ func TestToBuildOptionsTarget(t *testing.T) {
|
||||
c.Run(test.target, func(c *qt.C) {
|
||||
opts := Options{
|
||||
ExternalOptions: ExternalOptions{
|
||||
Target: test.target,
|
||||
Target: []string{test.target},
|
||||
},
|
||||
InternalOptions: InternalOptions{
|
||||
MediaType: media.Builtin.JavascriptType,
|
||||
@@ -260,3 +260,13 @@ func TestDecodeExternalOptions(t *testing.T) {
|
||||
SourcesContent: api.SourcesContentInclude,
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecodeExternalOptionsLegacyTargetString(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
m := map[string]any{
|
||||
"target": "es2018",
|
||||
}
|
||||
ext, err := DecodeExternalOptions(m)
|
||||
c.Assert(err, qt.IsNil)
|
||||
c.Assert(ext.Target, qt.DeepEquals, []string{"es2018"})
|
||||
}
|
||||
|
||||
@@ -46,16 +46,20 @@ const (
|
||||
PrefixHugoMemory = "__hu_m"
|
||||
)
|
||||
|
||||
var extensionToLoaderMap = map[string]api.Loader{
|
||||
var extensionToLoaderMapJS = map[string]api.Loader{
|
||||
".js": api.LoaderJS,
|
||||
".mjs": api.LoaderJS,
|
||||
".cjs": api.LoaderJS,
|
||||
".jsx": api.LoaderJSX,
|
||||
".ts": api.LoaderTS,
|
||||
".tsx": api.LoaderTSX,
|
||||
".css": api.LoaderCSS,
|
||||
".json": api.LoaderJSON,
|
||||
".txt": api.LoaderText,
|
||||
".css": api.LoaderCSS,
|
||||
}
|
||||
|
||||
var extensionToLoaderMapCSS = map[string]api.Loader{
|
||||
".css": api.LoaderCSS,
|
||||
}
|
||||
|
||||
// This is a common sub-set of ESBuild's default extensions.
|
||||
@@ -93,6 +97,7 @@ func ResolveComponent[T any](impPath string, resolve func(string) (v T, found, i
|
||||
}
|
||||
|
||||
base := filepath.Base(impPath)
|
||||
|
||||
if base == "index" {
|
||||
// try index.esm.js etc.
|
||||
v, found, _ = findFirst(impPath + ".esm")
|
||||
@@ -140,7 +145,7 @@ type fsResolver struct {
|
||||
resolved *hmaps.Cache[string, *hugofs.FileMeta]
|
||||
}
|
||||
|
||||
func (r *fsResolver) resolveComponent(impPath string) *hugofs.FileMeta {
|
||||
func (r *fsResolver) resolveComponent(impPath string, direct bool) *hugofs.FileMeta {
|
||||
v, _ := r.resolved.GetOrCreate(impPath, func() (*hugofs.FileMeta, error) {
|
||||
resolve := func(name string) (*hugofs.FileMeta, bool, bool) {
|
||||
if fi, err := r.fs.Stat(name); err == nil {
|
||||
@@ -148,6 +153,14 @@ func (r *fsResolver) resolveComponent(impPath string) *hugofs.FileMeta {
|
||||
}
|
||||
return nil, false, false
|
||||
}
|
||||
if direct {
|
||||
// Only resolve the path as is, without trying to add extensions etc.
|
||||
v, found, isDir := resolve(impPath)
|
||||
if found && !isDir {
|
||||
return v, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
v, _ := ResolveComponent(impPath, resolve)
|
||||
return v, nil
|
||||
})
|
||||
@@ -159,6 +172,8 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
|
||||
|
||||
resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) {
|
||||
impPath := args.Path
|
||||
isCSSToken := args.Kind == api.ResolveCSSImportRule || args.Kind == api.ResolveCSSURLToken
|
||||
|
||||
shimmed := false
|
||||
if opts.Shims != nil {
|
||||
override, found := opts.Shims[impPath]
|
||||
@@ -182,9 +197,9 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
|
||||
}
|
||||
|
||||
importer := args.Importer
|
||||
|
||||
isStdin := importer == stdinImporter
|
||||
var relDir string
|
||||
|
||||
if !isStdin {
|
||||
if after, ok := strings.CutPrefix(importer, PrefixHugoVirtual); ok {
|
||||
relDir = filepath.Dir(after)
|
||||
@@ -208,23 +223,45 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
|
||||
relDir = opts.SourceDir
|
||||
}
|
||||
|
||||
// Imports not starting with a "." is assumed to live relative to /assets.
|
||||
// Hugo makes no assumptions about the directory structure below /assets.
|
||||
if relDir != "" && strings.HasPrefix(impPath, ".") {
|
||||
impPath = filepath.Join(relDir, impPath)
|
||||
var pathsToTry []string
|
||||
|
||||
if relDir != "" {
|
||||
// For JS imports not starting with a "." is assumed to live relative to /assets.
|
||||
// Hugo makes no assumptions about the directory structure below /assets.
|
||||
if strings.HasPrefix(impPath, ".") {
|
||||
pathsToTry = append(pathsToTry, filepath.Join(relDir, impPath))
|
||||
} else if isCSSToken && !strings.HasPrefix(impPath, "/") {
|
||||
// Follow the logic of both ESBuild and WebKit for CSS @import and url() tokens.
|
||||
// First try the relativ path, then the assets relative path.
|
||||
// See https://github.com/evanw/esbuild/issues/469
|
||||
pathsToTry = append(pathsToTry, filepath.Join(relDir, impPath), impPath)
|
||||
} else {
|
||||
// Try only the assets relative path.
|
||||
pathsToTry = append(pathsToTry, impPath)
|
||||
}
|
||||
}
|
||||
|
||||
m := assetsResolver.resolveComponent(impPath)
|
||||
var m *hugofs.FileMeta
|
||||
for _, p := range pathsToTry {
|
||||
m = assetsResolver.resolveComponent(p, isCSSToken)
|
||||
if m != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if m != nil {
|
||||
depsManager.AddIdentity(m.PathInfo)
|
||||
|
||||
// Store the source root so we can create a jsconfig.json
|
||||
// to help IntelliSense when the build is done.
|
||||
// This should be a small number of elements, and when
|
||||
// in server mode, we may get stale entries on renames etc.,
|
||||
// but that shouldn't matter too much.
|
||||
rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot)
|
||||
// jsconfig.json path mapping has no effect for CSS imports, so skip those.
|
||||
if !isCSSToken {
|
||||
// Store the source root so we can create a jsconfig.json
|
||||
// to help IntelliSense when the build is done.
|
||||
// This should be a small number of elements, and when
|
||||
// in server mode, we may get stale entries on renames etc.,
|
||||
// but that shouldn't matter too much.
|
||||
rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot)
|
||||
}
|
||||
|
||||
return api.OnResolveResult{Path: m.Filename, Namespace: NsHugoImport}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestResolveComponentInAssets(t *testing.T) {
|
||||
c.Assert(err, qt.IsNil)
|
||||
resolver := newFSResolver(bfs.Assets.Fs)
|
||||
|
||||
got := resolver.resolveComponent(test.impPath)
|
||||
got := resolver.resolveComponent(test.impPath, false)
|
||||
|
||||
gotPath := ""
|
||||
expect := test.expect
|
||||
|
||||
@@ -218,7 +218,7 @@ func (t *babelTransformation) Transform(ctx *resources.ResourceTransformationCtx
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = ctx.PublishSourceMap(string(sourceMap)); err != nil {
|
||||
if err = ctx.PublishSourceMap(sourceMap); err != nil {
|
||||
return err
|
||||
}
|
||||
targetPath := path.Base(ctx.OutPath) + ".map"
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
package js
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/evanw/esbuild/pkg/api"
|
||||
"github.com/gohugoio/hugo/hugolib/filesystems"
|
||||
@@ -31,9 +34,9 @@ type Client struct {
|
||||
}
|
||||
|
||||
// New creates a new client context.
|
||||
func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client {
|
||||
func New(fs *filesystems.SourceFilesystem, rs *resources.Spec, cssMode bool) *Client {
|
||||
return &Client{
|
||||
c: esbuild.NewBuildClient(fs, rs),
|
||||
c: esbuild.NewBuildClient(fs, rs, cssMode),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,27 +59,56 @@ func (c *Client) transform(opts esbuild.Options, transformCtx *resources.Resourc
|
||||
return result, err
|
||||
}
|
||||
|
||||
if opts.ExternalOptions.SourceMap == "linked" || opts.ExternalOptions.SourceMap == "external" {
|
||||
content := string(result.OutputFiles[1].Contents)
|
||||
if opts.ExternalOptions.SourceMap == "linked" {
|
||||
symPath := path.Base(transformCtx.OutPath) + ".map"
|
||||
re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`)
|
||||
content = re.ReplaceAllString(content, "//# sourceMappingURL="+symPath+"\n")
|
||||
}
|
||||
hasLinkedSourceMap := opts.ExternalOptions.SourceMap == "linked"
|
||||
hasSourceMap := hasLinkedSourceMap || opts.ExternalOptions.SourceMap == "external"
|
||||
|
||||
if err = transformCtx.PublishSourceMap(string(result.OutputFiles[0].Contents)); err != nil {
|
||||
return result, err
|
||||
// Classify output files by path rather than relying on array ordering,
|
||||
// which esbuild does not guarantee.
|
||||
var mainOutput []byte
|
||||
outDir := path.Dir(transformCtx.OutPath)
|
||||
for _, file := range result.OutputFiles {
|
||||
basePath := path.Base(filepath.ToSlash(file.Path))
|
||||
if strings.HasSuffix(basePath, ".map") {
|
||||
if hasSourceMap {
|
||||
if err = transformCtx.PublishSourceMap(file.Contents); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
} else if isStdinEntryOutput(basePath) {
|
||||
mainOutput = file.Contents
|
||||
} else {
|
||||
// File-loader artifact; publish directly.
|
||||
target := path.Join(outDir, basePath)
|
||||
if err = transformCtx.PublishTo(target, file.Contents); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
_, err := transformCtx.To.Write([]byte(content))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
} else {
|
||||
_, err := transformCtx.To.Write(result.OutputFiles[0].Contents)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if mainOutput == nil {
|
||||
return result, fmt.Errorf("esbuild: entry point output not found")
|
||||
}
|
||||
|
||||
if hasLinkedSourceMap {
|
||||
symPath := path.Base(transformCtx.OutPath) + ".map"
|
||||
if opts.IsCSS {
|
||||
re := regexp.MustCompile(`/\*# sourceMappingURL=.*\n?`)
|
||||
mainOutput = re.ReplaceAll(mainOutput, []byte("/*# sourceMappingURL="+symPath+" */\n"))
|
||||
} else {
|
||||
re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`)
|
||||
mainOutput = re.ReplaceAll(mainOutput, []byte("//# sourceMappingURL="+symPath+"\n"))
|
||||
}
|
||||
}
|
||||
|
||||
if _, err = transformCtx.To.Write(mainOutput); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// isStdinEntryOutput reports whether basePath is the entry-point output
|
||||
// generated by esbuild when using stdin mode (e.g. "stdin.js", "stdin.css").
|
||||
func isStdinEntryOutput(basePath string) bool {
|
||||
return strings.HasPrefix(basePath, "stdin.")
|
||||
}
|
||||
|
||||
@@ -30,11 +30,18 @@ type buildTransformation struct {
|
||||
}
|
||||
|
||||
func (t *buildTransformation) Key() internal.ResourceTransformationKey {
|
||||
return internal.NewResourceTransformationKey("jsbuild", t.optsm)
|
||||
key := "jsbuild"
|
||||
if t.c.c.CssMode {
|
||||
key = "cssbuild"
|
||||
}
|
||||
return internal.NewResourceTransformationKey(key, t.optsm)
|
||||
}
|
||||
|
||||
func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
|
||||
ctx.OutMediaType = media.Builtin.JavascriptType
|
||||
if ctx.InMediaType == media.Builtin.CSSType {
|
||||
ctx.OutMediaType = media.Builtin.CSSType
|
||||
}
|
||||
|
||||
var opts esbuild.Options
|
||||
|
||||
@@ -49,7 +56,7 @@ func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx
|
||||
if opts.TargetPath != "" {
|
||||
ctx.OutPath = opts.TargetPath
|
||||
} else {
|
||||
ctx.ReplaceOutPathExtension(".js")
|
||||
ctx.ReplaceOutPathExtension(ctx.OutMediaType.FirstSuffix.FullSuffix)
|
||||
}
|
||||
|
||||
src, err := io.ReadAll(ctx.From)
|
||||
@@ -61,6 +68,7 @@ func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx
|
||||
opts.Contents = string(src)
|
||||
opts.MediaType = ctx.InMediaType
|
||||
opts.Stdin = true
|
||||
opts.IsCSS = t.c.c.CssMode
|
||||
|
||||
_, err = t.c.transform(opts, ctx)
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ func (t *transform) Transform(ctx *resources.ResourceTransformationCtx) error {
|
||||
}
|
||||
|
||||
if opts.EnableSourceMap && res.SourceMap != "" {
|
||||
if err := ctx.PublishSourceMap(res.SourceMap); err != nil {
|
||||
if err := ctx.PublishSourceMap([]byte(res.SourceMap)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(ctx.To, "\n\n/*# sourceMappingURL=%s */", path.Base(ctx.OutPath)+".map")
|
||||
|
||||
@@ -177,7 +177,7 @@ func (t *toCSSTransformation) Transform(ctx *resources.ResourceTransformationCtx
|
||||
// is important enough to go this extra mile.
|
||||
mapContent := strings.Replace(res.SourceMapContent, `stdin"`, fmt.Sprintf("%s\"", sourcePath), 1)
|
||||
|
||||
return ctx.PublishSourceMap(mapContent)
|
||||
return ctx.PublishSourceMap([]byte(mapContent))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -146,14 +146,19 @@ func (ctx *ResourceTransformationCtx) AddOutPathIdentifier(identifier string) {
|
||||
|
||||
// PublishSourceMap writes the content to the target folder of the main resource
|
||||
// with the ".map" extension added.
|
||||
func (ctx *ResourceTransformationCtx) PublishSourceMap(content string) error {
|
||||
func (ctx *ResourceTransformationCtx) PublishSourceMap(content []byte) error {
|
||||
target := ctx.OutPath + ".map"
|
||||
return ctx.PublishTo(target, content)
|
||||
}
|
||||
|
||||
// PublishTo writes the content to the target folder.
|
||||
func (ctx *ResourceTransformationCtx) PublishTo(target string, content []byte) error {
|
||||
f, err := ctx.OpenResourcePublisher(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.Write([]byte(content))
|
||||
_, err = f.Write(content)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
// Copyright 2026 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 css_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gohugoio/hugo/htesting"
|
||||
"github.com/gohugoio/hugo/hugolib"
|
||||
)
|
||||
|
||||
func TestCSSBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := `
|
||||
-- hugo.toml --
|
||||
-- assets/a/pixel.png --
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
|
||||
-- assets/css/main.css --
|
||||
@import "tailwindcss";
|
||||
@import url('https://example.org/foo.css');
|
||||
@import "./foo.css";
|
||||
@import "./bar.css" layer(mylayer);
|
||||
@import "./bar.css" layer;
|
||||
@import './baz.css' screen and (min-width: 800px);
|
||||
@import 'relnodot.css' supports(display: grid);
|
||||
@import 'relnodotboth.css';
|
||||
@import '/relnodotboth2.css';
|
||||
body {
|
||||
background-image: url("a/pixel.png");
|
||||
}
|
||||
.mask1 {
|
||||
mask-image: url(a/pixel.png);
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
-- assets/css/foo.css --
|
||||
p { background: red; }
|
||||
-- assets/css/bar.css --
|
||||
div { background: blue; }
|
||||
-- assets/css/baz.css --
|
||||
span { background: green; }
|
||||
-- assets/css/relnodot.css --
|
||||
article { background: yellow; }
|
||||
-- assets/css/relnodotboth.css --
|
||||
.relnodotbothrelative { background: green; }
|
||||
-- assets/relnodotboth.css --
|
||||
.relnodotbothroot { background: blue; }
|
||||
-- assets/css/relnodotboth2.css --
|
||||
.relnodotboth2relative { background: green; }
|
||||
-- assets/relnodotboth2.css --
|
||||
.relnodotboth2root { background: blue; }
|
||||
-- layouts/home.html --
|
||||
{{ with resources.Get "css/main.css" }}
|
||||
{{ $opts := (dict "minify" true "target" (slice "chrome108" "firefox116" "safari16.4" "edge115" "ios16.4" "opera101") "loaders" (dict ".png" "dataurl") "externals" (slice "tailwindcss"))}}
|
||||
{{ with . | css.Build $opts }}
|
||||
<link rel="stylesheet" href="{{ .RelPermalink }}" />
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
`
|
||||
|
||||
b := hugolib.Test(t, files, hugolib.TestOptOsFs())
|
||||
b.AssertFileContent("public/css/main.css",
|
||||
`-webkit-mask-repeat`, // browser prefix.
|
||||
"data:image/png;base64", // dataurl loader
|
||||
`@import"tailwindcss";`, // external
|
||||
`@import"https://example.org/foo.css";`, // external
|
||||
"mylayer{div", // imported layer
|
||||
"@media screen and (min-width:800px){span", // imported media query
|
||||
"@supports (display: grid){article", // imported supports
|
||||
"relnodotbothrelative", //@import 'relnodotboth.css'; file exists in both assets and project root.
|
||||
"relnodotboth2root", //@import '/relnodotboth2.css'; file exists in both assets and project root.
|
||||
)
|
||||
}
|
||||
|
||||
func TestCSSBuildEdit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := `
|
||||
-- hugo.toml --
|
||||
disableKinds = ["taxonomy", "term", "rss"]
|
||||
-- assets/images/bar.svg --
|
||||
barsvg
|
||||
-- assets/images/foo.svg --
|
||||
foosvg
|
||||
-- assets/css/main.css --
|
||||
@import "./foo.css";
|
||||
@import "./bar.css" layer(mylayer);
|
||||
.main { background: red; }
|
||||
-- assets/css/foo.css --
|
||||
.foo { background: green; }
|
||||
-- assets/css/bar.css --
|
||||
.bar { background-image: url("images/bar.svg"); }
|
||||
-- layouts/all.html --
|
||||
All. No CSS here.
|
||||
-- layouts/home.html --
|
||||
{{ with resources.Get "css/main.css" }}
|
||||
{{ $opts := (dict "minify" true) }}
|
||||
{{ with . | css.Build $opts }}
|
||||
<link rel="stylesheet" href="{{ .RelPermalink }}" />
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
`
|
||||
b := hugolib.TestRunning(t, files, hugolib.TestOptOsFs())
|
||||
|
||||
b.AssertFileContent("public/css/main.css", `.foo{background:green}@layer mylayer{.bar{background-image:url("./bar-Y35ORVQM.svg")}}`)
|
||||
|
||||
// Edit svg
|
||||
b.EditFileReplaceAll("assets/images/bar.svg", "barsvg", "newbarsvg").Build()
|
||||
b.AssertRenderCountPage(1)
|
||||
b.AssertFileContent("public/css/main.css", `bar-LVHHRPN5.svg`) // new hash.
|
||||
b.AssertFileContent("public/css/bar-LVHHRPN5.svg", "newbarsvg")
|
||||
|
||||
// Edit foo.css
|
||||
b.EditFileReplaceAll("assets/css/foo.css", "green", "red").Build()
|
||||
b.AssertRenderCountPage(1)
|
||||
b.AssertFileContent("public/css/main.css", `.foo{background:red}`) // updated content.
|
||||
|
||||
// Edit bar.css
|
||||
b.EditFileReplaceAll("assets/css/bar.css", "bar.svg", "foo.svg").Build()
|
||||
b.AssertRenderCountPage(1)
|
||||
b.AssertFileContent("public/css/main.css", `foo-52JTT5GU.svg`)
|
||||
b.AssertFileContent("public/css/foo-52JTT5GU.svg", "foosvg")
|
||||
|
||||
// Edit main.css
|
||||
b.EditFileReplaceAll("assets/css/main.css", "red", "blue").Build()
|
||||
b.AssertRenderCountPage(1)
|
||||
b.AssertFileContent("public/css/main.css", `.main{background:#00f}`) // updated content, blue gets minified to #00f.
|
||||
}
|
||||
|
||||
func TestCSSBuildSourceMaps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesTemplate := `
|
||||
-- hugo.toml --
|
||||
-- assets/a/pixel.png --
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
|
||||
-- assets/css/main.css --
|
||||
@import "./foo.css";
|
||||
@import "./bar.css" layer(mylayer);
|
||||
@import "./bar.css" layer;
|
||||
@import './baz.css' screen and (min-width: 800px);
|
||||
|
||||
.main {
|
||||
background-color: red;
|
||||
}
|
||||
-- assets/css/foo.css --
|
||||
p { background: red; }
|
||||
-- assets/css/bar.css --
|
||||
div { background: blue; }
|
||||
-- assets/css/baz.css --
|
||||
span { background: green; }
|
||||
-- assets/css/qux.css --
|
||||
article { background: yellow; }
|
||||
-- layouts/home.html --
|
||||
{{ with resources.Get "css/main.css" }}
|
||||
{{ $opts := (dict "minify" false "sourceMap" "SOURCE_MAP" "sourcesContent" SOURCES_CONTENT "loaders" (dict ".png" "dataurl"))}}
|
||||
{{ with . | css.Build $opts }}
|
||||
<link rel="stylesheet" href="{{ .RelPermalink }}" />
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
`
|
||||
|
||||
r := strings.NewReplacer(
|
||||
"SOURCE_MAP", "linked",
|
||||
"SOURCES_CONTENT", "true",
|
||||
)
|
||||
|
||||
files := r.Replace(filesTemplate)
|
||||
|
||||
b := hugolib.Test(t, files, hugolib.TestOptOsFs())
|
||||
b.AssertFileContent("public/css/main.css", "/*# sourceMappingURL=main.css.map */")
|
||||
b.AssertFileContent("public/css/main.css.map",
|
||||
`"sourcesContent":["p { background: red; }"`,
|
||||
"AAAA;AAAI,cAAY;AAAK",
|
||||
)
|
||||
|
||||
r = strings.NewReplacer(
|
||||
"SOURCE_MAP", "external",
|
||||
"SOURCES_CONTENT", "true",
|
||||
)
|
||||
|
||||
files = r.Replace(filesTemplate)
|
||||
|
||||
b = hugolib.Test(t, files, hugolib.TestOptOsFs())
|
||||
b.AssertFileContent("public/css/main.css", "! sourceMappingURL")
|
||||
b.AssertFileContent("public/css/main.css.map",
|
||||
`"sourcesContent":["p { background: red; }"`,
|
||||
"AAAA;AAAI,cAAY;AAAK",
|
||||
)
|
||||
|
||||
r = strings.NewReplacer(
|
||||
"SOURCE_MAP", "external",
|
||||
"SOURCES_CONTENT", "false",
|
||||
)
|
||||
|
||||
files = r.Replace(filesTemplate)
|
||||
|
||||
b = hugolib.Test(t, files, hugolib.TestOptOsFs())
|
||||
b.AssertFileContent("public/css/main.css.map",
|
||||
`"sourcesContent":null,"`,
|
||||
"AAAA;AAAI,cAAY;AAAK",
|
||||
)
|
||||
|
||||
r = strings.NewReplacer(
|
||||
"SOURCE_MAP", "inline",
|
||||
"SOURCES_CONTENT", "false",
|
||||
)
|
||||
|
||||
files = r.Replace(filesTemplate)
|
||||
|
||||
b = hugolib.Test(t, files, hugolib.TestOptOsFs())
|
||||
b.AssertFileExists("public/css/main.css.map", false)
|
||||
b.AssertFileContent("public/css/main.css", "sourceMappingURL=data:application/json;base64,")
|
||||
}
|
||||
|
||||
func TestCSSBuildMultihost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := `
|
||||
-- hugo.toml --
|
||||
disableKinds = ["taxonomy", "term"]
|
||||
defaultContentLanguage = "en"
|
||||
defaultContentLanguageInSubDir = true
|
||||
[languages]
|
||||
[languages.en]
|
||||
baseURL = "https://example.en"
|
||||
weight = 1
|
||||
[languages.fr]
|
||||
baseURL = "https://example.fr"
|
||||
weight = 2
|
||||
-- assets/a/pixel.png --
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
|
||||
-- assets/css/main.css --
|
||||
body {
|
||||
background-image: url("a/pixel.png");
|
||||
color: red;
|
||||
}
|
||||
-- layouts/home.html --
|
||||
{{ with resources.Get "css/main.css" }}
|
||||
{{ with . | css.Build (dict "minify" true) }}
|
||||
CSS: {{ .RelPermalink }}|{{ .Content }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
`
|
||||
|
||||
b := hugolib.Test(t, files, hugolib.TestOptOsFs())
|
||||
|
||||
for _, lang := range []string{"en", "fr"} {
|
||||
b.AssertFileContent("public/"+lang+"/css/main.css", `./pixel-NJRUOINY.png`)
|
||||
b.AssertFileExists("public/"+lang+"/css/pixel-NJRUOINY.png", true)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSSBuildLoadersDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := `
|
||||
-- hugo.toml --
|
||||
-- assets/a/pixel.png --
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
|
||||
-- assets/css/main.css --
|
||||
body {
|
||||
background-image: url("a/pixel.png");
|
||||
}
|
||||
-- layouts/home.html --
|
||||
{{ with resources.Get "css/main.css" }}
|
||||
{{ with . | css.Build (dict "minify" true) }}
|
||||
<link rel="stylesheet" href="{{ .RelPermalink }}" />
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
`
|
||||
|
||||
b := hugolib.Test(t, files, hugolib.TestOptOsFs())
|
||||
b.AssertFileContent("public/css/main.css", `./pixel-NJRUOINY.png`)
|
||||
b.AssertFileExists("public/css/pixel-NJRUOINY.png", true)
|
||||
}
|
||||
|
||||
func TestCSSBuildBootstrapFromNPM(t *testing.T) {
|
||||
htesting.SkipSlowTestUnlessCI(t)
|
||||
t.Parallel()
|
||||
|
||||
files := `
|
||||
-- hugo.toml --
|
||||
disableKinds = ["taxonomy", "term"]
|
||||
-- assets/css/main.css --
|
||||
@import "bootstrap";
|
||||
-- package.json --
|
||||
{
|
||||
"devDependencies": {
|
||||
"bootstrap": "5.3.8"
|
||||
}
|
||||
}
|
||||
-- layouts/home.html --
|
||||
{{ with resources.Get "css/main.css" }}
|
||||
{{ with . | css.Build (dict "minify" true "mainFields" (slice "style")) }}
|
||||
CSS size: {{ .Content | len }}|{{ .RelPermalink }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
`
|
||||
|
||||
b := hugolib.Test(t, files, hugolib.TestOptOsFs(), hugolib.TestOptWithNpmInstall())
|
||||
b.AssertFileContent("public/css/main.css", `--bs-indigo: #6610f2;`)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/gohugoio/hugo/resources/resource"
|
||||
"github.com/gohugoio/hugo/resources/resource_transformers/babel"
|
||||
"github.com/gohugoio/hugo/resources/resource_transformers/cssjs"
|
||||
jstransform "github.com/gohugoio/hugo/resources/resource_transformers/js"
|
||||
"github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass"
|
||||
"github.com/gohugoio/hugo/resources/resource_transformers/tocss/sass"
|
||||
"github.com/gohugoio/hugo/resources/resource_transformers/tocss/scss"
|
||||
@@ -32,6 +33,7 @@ type Namespace struct {
|
||||
postcssClient *cssjs.PostCSSClient
|
||||
tailwindcssClient *cssjs.TailwindCSSClient
|
||||
babelClient *babel.Client
|
||||
jsTransformClient *jstransform.Client
|
||||
|
||||
// The Dart Client requires a os/exec process, so only
|
||||
// create it if we really need it.
|
||||
@@ -147,6 +149,33 @@ func (ns *Namespace) Sass(args ...any) (resource.Resource, error) {
|
||||
return client.ToCSS(r, m)
|
||||
}
|
||||
|
||||
// Build processes the given CSS Resource with ESBuild.
|
||||
// Note that this method is identical to the one in the js Namespace.
|
||||
func (ns *Namespace) Build(args ...any) (resource.Resource, error) {
|
||||
var (
|
||||
r resources.ResourceTransformer
|
||||
m map[string]any
|
||||
targetPath string
|
||||
err error
|
||||
ok bool
|
||||
)
|
||||
|
||||
r, targetPath, ok = resourcehelpers.ResolveIfFirstArgIsString(args)
|
||||
|
||||
if !ok {
|
||||
r, m, err = resourcehelpers.ResolveArgs(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if targetPath != "" {
|
||||
m = map[string]any{"targetPath": targetPath}
|
||||
}
|
||||
|
||||
return ns.jsTransformClient.Process(r, m)
|
||||
}
|
||||
|
||||
func init() {
|
||||
f := func(d *deps.Deps) *internal.TemplateFuncsNamespace {
|
||||
scssClient, err := scss.New(d.BaseFs.Assets, d.ResourceSpec)
|
||||
@@ -159,6 +188,7 @@ func init() {
|
||||
postcssClient: cssjs.NewPostCSSClient(d.ResourceSpec),
|
||||
tailwindcssClient: cssjs.NewTailwindCSSClient(d.ResourceSpec),
|
||||
babelClient: babel.New(d.ResourceSpec),
|
||||
jsTransformClient: jstransform.New(d.BaseFs.Assets, d.ResourceSpec, true),
|
||||
}
|
||||
|
||||
ns := &internal.TemplateFuncsNamespace{
|
||||
@@ -181,6 +211,11 @@ func init() {
|
||||
[][2]string{},
|
||||
)
|
||||
|
||||
ns.AddMethodMapping(ctx.Build,
|
||||
nil,
|
||||
[][2]string{},
|
||||
)
|
||||
|
||||
ns.AddMethodMapping(ctx.TailwindCSS,
|
||||
nil,
|
||||
[][2]string{},
|
||||
|
||||
+2
-2
@@ -36,7 +36,7 @@ func New(d *deps.Deps) (*Namespace, error) {
|
||||
|
||||
return &Namespace{
|
||||
d: d,
|
||||
jsTransformClient: jstransform.New(d.BaseFs.Assets, d.ResourceSpec),
|
||||
jsTransformClient: jstransform.New(d.BaseFs.Assets, d.ResourceSpec, false),
|
||||
createClient: create.New(d.ResourceSpec),
|
||||
babelClient: babel.New(d.ResourceSpec),
|
||||
}, nil
|
||||
@@ -51,7 +51,7 @@ type Namespace struct {
|
||||
babelClient *babel.Client
|
||||
}
|
||||
|
||||
// Build processes the given Resource with ESBuild.
|
||||
// Build processes the given JavaScript Resource with ESBuild.
|
||||
func (ns *Namespace) Build(args ...any) (resource.Resource, error) {
|
||||
var (
|
||||
r resources.ResourceTransformer
|
||||
|
||||
Reference in New Issue
Block a user