Files
2026-07-13 11:58:46 +08:00

647 lines
19 KiB
Go

// 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 npm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"github.com/gohugoio/hugo/common/hashing"
"github.com/gohugoio/hugo/common/hmaps"
"github.com/gohugoio/hugo/hugofs/files"
"github.com/gohugoio/hugo/modules"
"github.com/gohugoio/hugo/hugofs"
"github.com/spf13/afero"
"github.com/gohugoio/hugo/helpers"
)
const (
dependenciesKey = "dependencies"
devDependenciesKey = "devDependencies"
packageJSONName = "package.json"
packageMetaJSONName = "hugo_packagemeta.json"
)
var (
workspacePackageJSON = filepath.Join(files.FolderPackagesHugoAutoGen, packageJSONName)
workspacePackageMetaJSON = filepath.Join(files.FolderPackagesHugoAutoGen, packageMetaJSONName)
)
func Pack(sourceFs, assetsWithDuplicatesPreservedFs afero.Fs, mods modules.Modules) error {
b := &packageBuilder{
devDependencies: make(map[string]any),
devDependenciesComments: make(map[string]any),
dependencies: make(map[string]any),
dependenciesComments: make(map[string]any),
}
workspacePath := filepath.ToSlash(files.FolderPackagesHugoAutoGen)
// skip modules that shouldn't have their package files processed, either because they are the project module (handled separately)
// or because their UsePackageJSON setting disables it.
skipPackageJSON := buildSkipPackageJSON(mods)
// 1. Read project deps: prefer package.hugo.json, fall back to package.json.
var rootPkg map[string]any
rootData, err := afero.ReadFile(sourceFs, packageJSONName)
if err == nil {
rootPkg = b.unmarshal(bytes.NewReader(rootData))
if b.err != nil {
return fmt.Errorf("npm pack: failed to parse package.json: %w", b.err)
}
}
// Workspaces source: prefer package.hugo.json, fall back to package.json.
var workspacesSource map[string]any
hugoData, hugoErr := afero.ReadFile(sourceFs, files.FilenamePackageHugoJSON)
if hugoErr == nil {
hugoPkg := b.unmarshal(bytes.NewReader(hugoData))
if b.err != nil {
return fmt.Errorf("npm pack: failed to parse %s: %w", files.FilenamePackageHugoJSON, b.err)
}
b.addm("project", hugoPkg)
workspacesSource = hugoPkg
} else if rootPkg != nil {
b.addm("project", rootPkg)
workspacesSource = rootPkg
}
// 2. Read deps from referenced workspaces (always from package.json).
for _, wsDir := range resolveProjectWorkspaces(sourceFs, workspacesSource, workspacePath) {
wsFile := filepath.Join(wsDir, packageJSONName)
wsData, err := afero.ReadFile(sourceFs, wsFile)
if err != nil {
continue
}
wsm := b.unmarshal(bytes.NewReader(wsData))
if b.err != nil {
return fmt.Errorf("npm pack: failed to parse %s: %w", wsFile, b.err)
}
b.addm("project", wsm)
}
// 3. Walk _jsconfig for module deps.
// We use hugofs.Walkway (which uses ReadDir) instead of afero.Walk because
// afero.Walk uses Readdirnames+Stat, which loses the per-module identity
// when multiple modules mount a file (e.g. package.json) to the same virtual path.
// Note that the order of files here is in the order of importance.
type pkgEntry struct {
info hugofs.FileMetaInfo
isHugoJSON bool
isRootLevel bool
}
var entries []pkgEntry
modulesWithHugoJSON := make(map[string]bool)
w := hugofs.NewWalkway(hugofs.WalkwayConfig{
Fs: assetsWithDuplicatesPreservedFs,
Root: files.FolderJSConfig,
WalkFn: func(ctx context.Context, path string, info hugofs.FileMetaInfo) error {
if info.IsDir() {
return nil
}
isPackageJSON := info.Name() == files.FilenamePackageJSON
isHugoJSON := info.Name() == files.FilenamePackageHugoJSON
if !isPackageJSON && !isHugoJSON {
return nil
}
m := info.Meta()
if skipPackageJSON[m.ModulePath()] {
return nil
}
// package.hugo.json is only valid at module roots, not inside workspaces.
isRootLevel := filepath.Dir(path) == files.FolderJSConfig
if isHugoJSON && !isRootLevel {
return nil
}
if isHugoJSON {
modulesWithHugoJSON[m.ModulePath()] = true
}
entries = append(entries, pkgEntry{info: info, isHugoJSON: isHugoJSON, isRootLevel: isRootLevel})
return nil
},
})
if err := w.Walk(); err != nil {
return err
}
// Process collected entries: for each module, prefer package.hugo.json
// over package.json at the root level. Workspace package.json files are always processed.
for _, e := range entries {
m := e.info.Meta()
// Skip root-level package.json if this module has package.hugo.json.
if !e.isHugoJSON && e.isRootLevel && modulesWithHugoJSON[m.ModulePath()] {
continue
}
f, err := m.Open()
if err != nil {
return fmt.Errorf("npm pack: failed to open package file: %w", err)
}
b.Add(m.ModulePath(), f)
f.Close()
}
if b.Err() != nil {
return fmt.Errorf("npm pack: failed to build: %w", b.Err())
}
// 4. Build the autogenerated workspace package.json.
// Exclude deps already defined by the project itself — they don't
// need to be duplicated in the workspace and it simplifies maintenance.
moduleDeps := make(map[string]any)
moduleDepsComments := make(map[string]any)
for k, v := range b.dependencies {
if b.dependenciesComments[k] != "project" {
moduleDeps[k] = v
moduleDepsComments[k] = b.dependenciesComments[k]
}
}
moduleDevDeps := make(map[string]any)
moduleDevDepsComments := make(map[string]any)
for k, v := range b.devDependencies {
if b.devDependenciesComments[k] != "project" {
moduleDevDeps[k] = v
moduleDevDepsComments[k] = b.devDependenciesComments[k]
}
}
name := "project"
rfi, err := sourceFs.Stat("")
if err == nil {
name = rfi.Name()
}
autoGenPkg := map[string]any{
"name": name,
"version": "0.1.0",
dependenciesKey: moduleDeps,
devDependenciesKey: moduleDevDeps,
}
if err := sourceFs.MkdirAll(files.FolderPackagesHugoAutoGen, 0o777); err != nil {
return err
}
if err := writeJSON(sourceFs, workspacePackageJSON, autoGenPkg); err != nil {
return err
}
// 5. Ensure root package.json references the workspace.
if err := ensureWorkspaceRef(sourceFs, workspacePath); err != nil {
return err
}
// 6. Write metadata for the final on-disk package state.
metaFile := packageMeta{
Sum: PackageFilesSum(sourceFs, mods),
DependencySources: dependencySources{
Dependencies: moduleDepsComments,
DevDependencies: moduleDevDepsComments,
},
}
return writeJSON(sourceFs, workspacePackageMetaJSON, metaFile)
}
// ensureWorkspaceRef adds workspacePath to the "workspaces" array in root
// package.json with minimal formatting changes.
func ensureWorkspaceRef(fsys afero.Fs, workspacePath string) error {
data, err := afero.ReadFile(fsys, packageJSONName)
if err != nil {
content := fmt.Sprintf("{\n \"workspaces\": [\n %q\n ]\n}\n", workspacePath)
return afero.WriteFile(fsys, packageJSONName, []byte(content), 0o666)
}
if runtime.GOOS == "windows" {
data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n"))
}
// Parse to check if already present.
var pkg map[string]any
if err := json.Unmarshal(data, &pkg); err != nil {
return fmt.Errorf("npm pack: failed to parse package.json: %w", err)
}
indent := detectIndent(data)
quoted := fmt.Sprintf("%q", workspacePath)
wsVal, hasWS := pkg["workspaces"]
switch v := wsVal.(type) {
case []any:
// Array form: ["pkg-a", "pkg-b", ...]
if slices.Contains(toStringSlice(v), workspacePath) {
if runtime.GOOS == "windows" {
return afero.WriteFile(fsys, packageJSONName, data, 0o666)
}
return nil
}
// Fall through to byte-based insertion into the existing array below.
case map[string]any:
// Object form: { "workspaces": { "packages": [...] } }
packagesVal, ok := v["packages"]
if !ok {
return fmt.Errorf("npm pack: unsupported workspaces object; missing \"packages\" field")
}
packagesSlice := toStringSlice(packagesVal)
if slices.Contains(packagesSlice, workspacePath) {
if runtime.GOOS == "windows" {
return afero.WriteFile(fsys, packageJSONName, data, 0o666)
}
return nil
}
// Append the new workspace path to the packages slice.
newPkgs := make([]any, 0, len(packagesSlice)+1)
for _, s := range packagesSlice {
newPkgs = append(newPkgs, s)
}
newPkgs = append(newPkgs, workspacePath)
v["packages"] = newPkgs
updated, err := json.MarshalIndent(pkg, "", indent)
if err != nil {
return fmt.Errorf("npm pack: failed to marshal package.json with updated workspaces: %w", err)
}
updated = append(updated, '\n')
return afero.WriteFile(fsys, packageJSONName, updated, 0o666)
case nil:
// Treat explicit null as "no workspaces"; handled below as missing key.
default:
return fmt.Errorf("npm pack: unsupported workspaces type %T", v)
}
// Try adding to existing workspaces array when present.
if hasWS && wsVal != nil {
if wsIdx := bytes.Index(data, []byte(`"workspaces"`)); wsIdx >= 0 {
rest := data[wsIdx:]
if bracketOpen := bytes.IndexByte(rest, '['); bracketOpen >= 0 {
if bracketClose := bytes.IndexByte(rest[bracketOpen:], ']'); bracketClose >= 0 {
pos := wsIdx + bracketOpen + bracketClose
arrayContent := bytes.TrimSpace(data[wsIdx+bracketOpen+1 : pos])
var insertion string
if len(arrayContent) == 0 {
insertion = "\n" + indent + indent + quoted + "\n" + indent
} else {
insertion = ",\n" + indent + indent + quoted + "\n" + indent
}
result := make([]byte, 0, len(data)+len(insertion))
result = append(result, data[:pos]...)
result = append(result, insertion...)
result = append(result, data[pos:]...)
return afero.WriteFile(fsys, packageJSONName, result, 0o666)
}
}
}
// We know a workspaces array exists but could not locate it reliably in the raw data.
return fmt.Errorf("npm pack: could not locate existing workspaces array for insertion")
}
// No workspaces key — add before closing brace.
lastBrace := bytes.LastIndexByte(data, '}')
if lastBrace < 0 {
return fmt.Errorf("npm pack: malformed package.json")
}
before := bytes.TrimRight(data[:lastBrace], " \t\n\r")
needComma := len(before) > 0 && before[len(before)-1] != '{' && before[len(before)-1] != ','
var insertion string
if needComma {
insertion = ",\n" + indent + `"workspaces": [` + "\n" + indent + indent + quoted + "\n" + indent + "]\n"
} else {
insertion = indent + `"workspaces": [` + "\n" + indent + indent + quoted + "\n" + indent + "]\n"
}
result := make([]byte, 0, len(data)+len(insertion))
result = append(result, before...)
result = append(result, insertion...)
result = append(result, data[lastBrace:]...)
return afero.WriteFile(fsys, packageJSONName, result, 0o666)
}
func detectIndent(data []byte) string {
for line := range bytes.SplitSeq(data, []byte("\n")) {
trimmed := bytes.TrimLeft(line, " \t")
if len(trimmed) < len(line) && len(trimmed) > 0 && trimmed[0] == '"' {
return string(line[:len(line)-len(trimmed)])
}
}
return " "
}
func writeJSON(fs afero.Fs, filename string, v any) error {
buf := new(bytes.Buffer)
encoder := json.NewEncoder(buf)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", strings.Repeat(" ", 2))
if err := encoder.Encode(v); err != nil {
return fmt.Errorf("npm pack: failed to marshal JSON: %w", err)
}
return afero.WriteFile(fs, filename, buf.Bytes(), 0o666)
}
func toStringSlice(v any) []string {
if v == nil {
return nil
}
if s, ok := v.([]any); ok {
var out []string
for _, item := range s {
if str, ok := item.(string); ok {
out = append(out, str)
}
}
return out
}
return nil
}
// resolveProjectWorkspaces resolves workspace patterns from the project's
// package source, skipping the hugoautogen workspace.
func resolveProjectWorkspaces(sourceFs afero.Fs, workspacesSource map[string]any, skipPath string) []string {
if workspacesSource == nil {
return nil
}
var dirs []string
for _, ws := range toStringSlice(workspacesSource["workspaces"]) {
for _, wsDir := range modules.ResolveWorkspacePattern(sourceFs, "", ws) {
if filepath.ToSlash(wsDir) != skipPath {
dirs = append(dirs, wsDir)
}
}
}
return dirs
}
type packageBuilder struct {
err error
devDependencies map[string]any
devDependenciesComments map[string]any
dependencies map[string]any
dependenciesComments map[string]any
}
func (b *packageBuilder) Add(source string, r io.Reader) *packageBuilder {
if b.err != nil {
return b
}
m := b.unmarshal(r)
if b.err != nil {
return b
}
b.addm(source, m)
return b
}
func (b *packageBuilder) addm(source string, m map[string]any) {
if source == "" {
source = "project"
}
// First version for a given dependency wins.
// Packages added by order of import (project, module1, module2...),
// so the project has control over versions.
if devDeps, found := m[devDependenciesKey]; found {
mm := hmaps.ToStringMapString(devDeps)
for k, v := range mm {
if _, added := b.devDependencies[k]; !added {
b.devDependencies[k] = v
b.devDependenciesComments[k] = source
}
}
}
if deps, found := m[dependenciesKey]; found {
mm := hmaps.ToStringMapString(deps)
for k, v := range mm {
if _, added := b.dependencies[k]; !added {
b.dependencies[k] = v
b.dependenciesComments[k] = source
}
}
}
}
func (b *packageBuilder) unmarshal(r io.Reader) map[string]any {
m := make(map[string]any)
err := json.Unmarshal(helpers.ReaderToBytes(r), &m)
if err != nil {
b.err = err
}
return m
}
func (b *packageBuilder) Err() error {
return b.err
}
// buildSkipPackageJSON determines which modules should NOT have their package
// files processed. The project module is always skipped (handled separately).
// For other modules, the behavior is controlled by the UsePackageJSON import
// setting: "auto" (default) reads package files when a Hugo config file or
// package.hugo.json is present; "always" always reads; "never" never reads.
func buildSkipPackageJSON(mods modules.Modules) map[string]bool {
skip := make(map[string]bool)
for _, m := range mods {
if m.Owner() == nil {
skip[m.Path()] = true
continue
}
if !usePackageJSON(m) {
skip[m.Path()] = true
}
}
return skip
}
// usePackageJSON checks the import config for this module and applies the
// UsePackageJSON setting. For "auto", it checks for Hugo config files or
// package.hugo.json in the module root.
func usePackageJSON(m modules.Module) bool {
setting := findImportSetting(m)
switch setting {
case modules.UsePackageJSONAlways:
return true
case modules.UsePackageJSONNever:
return false
default:
// "auto": use if Hugo config file or package.hugo.json is present.
if len(m.ConfigFilenames()) > 0 {
return true
}
if m.Dir() != "" {
if _, err := os.Stat(filepath.Join(m.Dir(), files.FilenamePackageHugoJSON)); err == nil {
return true
}
}
return false
}
}
func findImportSetting(m modules.Module) string {
if m.Owner() == nil {
return modules.UsePackageJSONAuto
}
for _, imp := range m.Owner().Config().Imports {
if imp.Path == m.Path() {
if imp.UsePackageJSON != "" {
return imp.UsePackageJSON
}
return modules.UsePackageJSONAuto
}
}
return modules.UsePackageJSONAuto
}
type packageMeta struct {
// Sum is a hash of all package files that feed into the npm pack output.
Sum string `json:"sum"`
DependencySources dependencySources `json:"dependencySources"`
}
type dependencySources struct {
Dependencies map[string]any `json:"dependencies"`
DevDependencies map[string]any `json:"devDependencies"`
}
// PackageFilesSum hashes the package files that Pack would use,
// using the same file selection logic as Pack.
func PackageFilesSum(sourceFs afero.Fs, mods modules.Modules) string {
h := hashing.XxHasher()
defer h.Close()
var w io.Writer = h
if runtime.GOOS == "windows" {
w = &crlfReplacer{w: h}
}
copyFile := func(fsys afero.Fs, name string) {
f, err := fsys.Open(name)
if err != nil {
return
}
defer f.Close()
io.Copy(w, f)
}
copyOsFile := func(name string) {
f, err := os.Open(name)
if err != nil {
return
}
defer f.Close()
io.Copy(w, f)
}
// Project level: prefer package.hugo.json, fall back to package.json.
// We need to parse whichever file we pick to discover workspaces.
var workspacesSource map[string]any
if data, err := afero.ReadFile(sourceFs, files.FilenamePackageHugoJSON); err == nil {
w.Write(data)
var m map[string]any
if err := json.Unmarshal(data, &m); err == nil {
workspacesSource = m
}
} else if data, err := afero.ReadFile(sourceFs, packageJSONName); err == nil {
w.Write(data)
var m map[string]any
if err := json.Unmarshal(data, &m); err == nil {
workspacesSource = m
}
}
// Workspace package.json files (skipping hugoautogen).
workspacePath := filepath.ToSlash(files.FolderPackagesHugoAutoGen)
for _, wsDir := range resolveProjectWorkspaces(sourceFs, workspacesSource, workspacePath) {
copyFile(sourceFs, filepath.Join(wsDir, packageJSONName))
}
// Module package files: prefer package.hugo.json, fall back to package.json.
skip := buildSkipPackageJSON(mods)
modulesWithHugoJSON := make(map[string]bool)
for _, m := range mods {
if skip[m.Path()] || m.Dir() == "" {
continue
}
if _, err := os.Stat(filepath.Join(m.Dir(), files.FilenamePackageHugoJSON)); err == nil {
modulesWithHugoJSON[m.Path()] = true
}
}
for _, m := range mods {
if skip[m.Path()] || m.Dir() == "" {
continue
}
if modulesWithHugoJSON[m.Path()] {
copyOsFile(filepath.Join(m.Dir(), files.FilenamePackageHugoJSON))
} else {
copyOsFile(filepath.Join(m.Dir(), packageJSONName))
}
}
return fmt.Sprintf("%x", h.Sum64())
}
// crlfReplacer wraps a writer and strips \r bytes.
type crlfReplacer struct {
w io.Writer
}
func (c *crlfReplacer) Write(p []byte) (int, error) {
n := len(p)
_, err := c.w.Write(bytes.ReplaceAll(p, []byte("\r\n"), []byte("\n")))
return n, err
}
// NpmPackNeedsUpdate checks if the npm pack output is stale by comparing
// the stored hash against the current package files.
func NpmPackNeedsUpdate(sourceFs afero.Fs, mods modules.Modules) bool {
data, err := afero.ReadFile(sourceFs, workspacePackageMetaJSON)
if err != nil {
// No meta file means npm pack hasn't been run yet.
return false
}
// We only need the sum for this check.
meta := struct {
Sum string `json:"sum"`
}{}
if err := json.Unmarshal(data, &meta); err != nil || meta.Sum == "" {
return true
}
return meta.Sum != PackageFilesSum(sourceFs, mods)
}