diff --git a/CHANGELOG.md b/CHANGELOG.md index eee2f0a4775..3ef7bc4d7fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This adds support for custom registries hosted at a path other than `/`. Previously the registry had to be hosted at the domain level, like npm itself. +* Nested source maps use relative paths ([#289](https://github.com/evanw/esbuild/issues/289)) + + The original paths in nested source maps are now modified to be relative to the directory containing the source map. This means source maps from packages inside `node_modules` will stay inside `node_modules` in browser developer tools instead of appearing at the root of the virtual file system where they might collide with the original paths of files in other packages. + ## 0.6.6 * Fix minification bug with `this` values for function calls ([#282](https://github.com/evanw/esbuild/issues/282)) diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index d8585e4ec2f..02bb43be5c5 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -330,7 +330,7 @@ func extractSourceMapFromComment(log logging.Log, fs fs.FS, source *logging.Sour return ast.Path{}, nil } contents := string(decoded) - return ast.Path{Text: "sourceMappingURL in " + source.PrettyPath}, &contents + return ast.Path{Text: source.PrettyPath + ".sourceMappingURL"}, &contents } } diff --git a/internal/parser/parser_sourcemap.go b/internal/parser/parser_sourcemap.go index e1be8e7d868..21343e9f356 100644 --- a/internal/parser/parser_sourcemap.go +++ b/internal/parser/parser_sourcemap.go @@ -2,6 +2,7 @@ package parser import ( "fmt" + "strings" "github.com/evanw/esbuild/internal/ast" "github.com/evanw/esbuild/internal/lexer" @@ -11,222 +12,231 @@ import ( // Specification: https://sourcemaps.info/spec.html func ParseSourceMap(log logging.Log, source logging.Source) *sourcemap.SourceMap { - if expr, ok := ParseJSON(log, source, ParseJSONOptions{}); ok { - if obj, ok := expr.Data.(*ast.EObject); !ok { - log.AddError(&source, expr.Loc, "Invalid source map") - } else { - var sourcesContent []*string - var sources []string - var mappingsRaw []uint16 - var mappingsRange ast.Range - hasVersion := false - - for _, prop := range obj.Properties { - keyRange := source.RangeOfString(prop.Key.Loc) - - switch lexer.UTF16ToString(prop.Key.Data.(*ast.EString).Value) { - case "sections": - log.AddRangeError(&source, keyRange, "Source maps with \"sections\" are not supported") - return nil + expr, ok := ParseJSON(log, source, ParseJSONOptions{}) + if !ok { + return nil + } - case "version": - if value, ok := prop.Value.Data.(*ast.ENumber); !ok { - log.AddRangeError(&source, keyRange, "The value for the \"version\" field must be a number") - return nil - } else { - if value.Value != 3 { - log.AddRangeError(&source, keyRange, "The source map \"version\" must be 3") - return nil - } - hasVersion = true - } + obj, ok := expr.Data.(*ast.EObject) + if !ok { + log.AddError(&source, expr.Loc, "Invalid source map") + return nil + } - case "mappings": - if value, ok := prop.Value.Data.(*ast.EString); !ok { - log.AddRangeError(&source, keyRange, "The value for the \"mappings\" field must be a string") - return nil - } else { - mappingsRaw = value.Value - mappingsRange = keyRange - } + var sourcesContent []*string + var sources []string + var mappingsRaw []uint16 + var mappingsRange ast.Range + hasVersion := false - case "sources": - if value, ok := prop.Value.Data.(*ast.EArray); !ok { - log.AddRangeError(&source, keyRange, "The value for the \"sources\" field must be an array") - return nil - } else { - sources = nil - for _, item := range value.Items { - if element, ok := item.Data.(*ast.EString); !ok { - log.AddError(&source, item.Loc, "Each element in the \"sources\" array must be a string") - return nil - } else { - sources = append(sources, lexer.UTF16ToString(element.Value)) - } - } - } + // Treat the paths in the source map as relative to the directory containing the source map + var sourcesPrefix string + if slash := strings.LastIndexAny(source.PrettyPath, "/\\"); slash != -1 { + sourcesPrefix = source.PrettyPath[:slash+1] + } - case "sourcesContent": - if value, ok := prop.Value.Data.(*ast.EArray); !ok { - log.AddRangeError(&source, keyRange, "The value for the \"sourcesContent\" field must be an array") - return nil - } else { - sourcesContent = nil - for _, item := range value.Items { - switch element := item.Data.(type) { - case *ast.EString: - str := lexer.UTF16ToString(element.Value) - sourcesContent = append(sourcesContent, &str) - case *ast.ENull: - sourcesContent = append(sourcesContent, nil) - default: - log.AddError(&source, item.Loc, "Each element in the \"sourcesContent\" array must be a string or null") - return nil - } - } - } + for _, prop := range obj.Properties { + keyRange := source.RangeOfString(prop.Key.Loc) + + switch lexer.UTF16ToString(prop.Key.Data.(*ast.EString).Value) { + case "sections": + log.AddRangeError(&source, keyRange, "Source maps with \"sections\" are not supported") + return nil + + case "version": + if value, ok := prop.Value.Data.(*ast.ENumber); !ok { + log.AddRangeError(&source, keyRange, "The value for the \"version\" field must be a number") + return nil + } else { + if value.Value != 3 { + log.AddRangeError(&source, keyRange, "The source map \"version\" must be 3") + return nil } + hasVersion = true } - if !hasVersion { - log.AddError(&source, expr.Loc, "This source map is missing the \"version\" field") + case "mappings": + if value, ok := prop.Value.Data.(*ast.EString); !ok { + log.AddRangeError(&source, keyRange, "The value for the \"mappings\" field must be a string") return nil + } else { + mappingsRaw = value.Value + mappingsRange = keyRange } - // Silently fail if the source map is pointless (i.e. empty) - if len(sources) == 0 || len(mappingsRaw) == 0 { + case "sources": + if value, ok := prop.Value.Data.(*ast.EArray); !ok { + log.AddRangeError(&source, keyRange, "The value for the \"sources\" field must be an array") return nil + } else { + sources = nil + for _, item := range value.Items { + if element, ok := item.Data.(*ast.EString); !ok { + log.AddError(&source, item.Loc, "Each element in the \"sources\" array must be a string") + return nil + } else { + sources = append(sources, sourcesPrefix+lexer.UTF16ToString(element.Value)) + } + } } - var mappings []sourcemap.Mapping - mappingsLen := len(mappingsRaw) - sourcesLen := len(sources) - generatedLine := 0 - generatedColumn := 0 - sourceIndex := 0 - originalLine := 0 - originalColumn := 0 - current := 0 - errorText := "" - - // Parse the mappings - for current < mappingsLen { - // Handle a line break - if mappingsRaw[current] == ';' { - generatedLine++ - generatedColumn = 0 - current++ - continue + case "sourcesContent": + if value, ok := prop.Value.Data.(*ast.EArray); !ok { + log.AddRangeError(&source, keyRange, "The value for the \"sourcesContent\" field must be an array") + return nil + } else { + sourcesContent = nil + for _, item := range value.Items { + switch element := item.Data.(type) { + case *ast.EString: + str := lexer.UTF16ToString(element.Value) + sourcesContent = append(sourcesContent, &str) + case *ast.ENull: + sourcesContent = append(sourcesContent, nil) + default: + log.AddError(&source, item.Loc, "Each element in the \"sourcesContent\" array must be a string or null") + return nil + } } + } + } + } - // Read the generated column - generatedColumnDelta, i, ok := sourcemap.DecodeVLQUTF16(mappingsRaw[current:]) - if !ok { - errorText = "Missing generated column" - break - } - current += i - if generatedColumnDelta < 0 { - // This would mess up binary search - errorText = "Unexpected generated column decrease" - break - } - generatedColumn += generatedColumnDelta - if generatedColumn < 0 { - errorText = fmt.Sprintf("Invalid generated column value: %d", generatedColumn) - break - } + if !hasVersion { + log.AddError(&source, expr.Loc, "This source map is missing the \"version\" field") + return nil + } - // According to the specification, it's valid for a mapping to have 1, - // 4, or 5 variable-length fields. Having one field means there's no - // original location information, which is pretty useless. Just ignore - // those entries. - if current == mappingsLen { - break - } - c := mappingsRaw[current] - if c == ',' || c == ';' { - current++ - continue - } + // Silently fail if the source map is pointless (i.e. empty) + if len(sources) == 0 || len(mappingsRaw) == 0 { + return nil + } - // Read the original source - sourceIndexDelta, i, ok := sourcemap.DecodeVLQUTF16(mappingsRaw[current:]) - if !ok { - errorText = "Missing source index" - break - } - current += i - sourceIndex += sourceIndexDelta - if sourceIndex < 0 || sourceIndex >= sourcesLen { - errorText = fmt.Sprintf("Invalid source index value: %d", sourceIndex) - break - } + var mappings []sourcemap.Mapping + mappingsLen := len(mappingsRaw) + sourcesLen := len(sources) + generatedLine := 0 + generatedColumn := 0 + sourceIndex := 0 + originalLine := 0 + originalColumn := 0 + current := 0 + errorText := "" + + // Parse the mappings + for current < mappingsLen { + // Handle a line break + if mappingsRaw[current] == ';' { + generatedLine++ + generatedColumn = 0 + current++ + continue + } - // Read the original line - originalLineDelta, i, ok := sourcemap.DecodeVLQUTF16(mappingsRaw[current:]) - if !ok { - errorText = "Missing original line" - break - } - current += i - originalLine += originalLineDelta - if originalLine < 0 { - errorText = fmt.Sprintf("Invalid original line value: %d", originalLine) - break - } + // Read the generated column + generatedColumnDelta, i, ok := sourcemap.DecodeVLQUTF16(mappingsRaw[current:]) + if !ok { + errorText = "Missing generated column" + break + } + current += i + if generatedColumnDelta < 0 { + // This would mess up binary search + errorText = "Unexpected generated column decrease" + break + } + generatedColumn += generatedColumnDelta + if generatedColumn < 0 { + errorText = fmt.Sprintf("Invalid generated column value: %d", generatedColumn) + break + } - // Read the original column - originalColumnDelta, i, ok := sourcemap.DecodeVLQUTF16(mappingsRaw[current:]) - if !ok { - errorText = "Missing original column" - break - } - current += i - originalColumn += originalColumnDelta - if originalColumn < 0 { - errorText = fmt.Sprintf("Invalid original column value: %d", originalColumn) - break - } + // According to the specification, it's valid for a mapping to have 1, + // 4, or 5 variable-length fields. Having one field means there's no + // original location information, which is pretty useless. Just ignore + // those entries. + if current == mappingsLen { + break + } + c := mappingsRaw[current] + if c == ',' || c == ';' { + current++ + continue + } - // Ignore the optional name index - if _, i, ok := sourcemap.DecodeVLQUTF16(mappingsRaw[current:]); ok { - current += i - } + // Read the original source + sourceIndexDelta, i, ok := sourcemap.DecodeVLQUTF16(mappingsRaw[current:]) + if !ok { + errorText = "Missing source index" + break + } + current += i + sourceIndex += sourceIndexDelta + if sourceIndex < 0 || sourceIndex >= sourcesLen { + errorText = fmt.Sprintf("Invalid source index value: %d", sourceIndex) + break + } - // Handle the next character - if current < mappingsLen { - if c := mappingsRaw[current]; c == ',' { - current++ - } else if c != ';' { - errorText = fmt.Sprintf("Invalid character after mapping: %q", - lexer.UTF16ToString(mappingsRaw[current:current+1])) - break - } - } + // Read the original line + originalLineDelta, i, ok := sourcemap.DecodeVLQUTF16(mappingsRaw[current:]) + if !ok { + errorText = "Missing original line" + break + } + current += i + originalLine += originalLineDelta + if originalLine < 0 { + errorText = fmt.Sprintf("Invalid original line value: %d", originalLine) + break + } - mappings = append(mappings, sourcemap.Mapping{ - GeneratedLine: int32(generatedLine), - GeneratedColumn: int32(generatedColumn), - SourceIndex: int32(sourceIndex), - OriginalLine: int32(originalLine), - OriginalColumn: int32(originalColumn), - }) - } + // Read the original column + originalColumnDelta, i, ok := sourcemap.DecodeVLQUTF16(mappingsRaw[current:]) + if !ok { + errorText = "Missing original column" + break + } + current += i + originalColumn += originalColumnDelta + if originalColumn < 0 { + errorText = fmt.Sprintf("Invalid original column value: %d", originalColumn) + break + } - if errorText != "" { - log.AddRangeError(&source, mappingsRange, - fmt.Sprintf("Bad \"mappings\" data in source map at character %d: %s", current, errorText)) - return nil - } + // Ignore the optional name index + if _, i, ok := sourcemap.DecodeVLQUTF16(mappingsRaw[current:]); ok { + current += i + } - return &sourcemap.SourceMap{ - Sources: sources, - SourcesContent: sourcesContent, - Mappings: mappings, + // Handle the next character + if current < mappingsLen { + if c := mappingsRaw[current]; c == ',' { + current++ + } else if c != ';' { + errorText = fmt.Sprintf("Invalid character after mapping: %q", + lexer.UTF16ToString(mappingsRaw[current:current+1])) + break } } + + mappings = append(mappings, sourcemap.Mapping{ + GeneratedLine: int32(generatedLine), + GeneratedColumn: int32(generatedColumn), + SourceIndex: int32(sourceIndex), + OriginalLine: int32(originalLine), + OriginalColumn: int32(originalColumn), + }) + } + + if errorText != "" { + log.AddRangeError(&source, mappingsRange, + fmt.Sprintf("Bad \"mappings\" data in source map at character %d: %s", current, errorText)) + return nil } - return nil + return &sourcemap.SourceMap{ + Sources: sources, + SourcesContent: sourcesContent, + Mappings: mappings, + } } diff --git a/internal/printer/printer.go b/internal/printer/printer.go index caa15dd4004..fd9563da4b7 100644 --- a/internal/printer/printer.go +++ b/internal/printer/printer.go @@ -2683,6 +2683,15 @@ func (p *printer) printStmt(stmt ast.Stmt) { } } +func (p *printer) shouldIgnoreSourceMap() bool { + for _, c := range p.sourceMap { + if c != ';' { + return false + } + } + return true +} + type PrintOptions struct { OutputFormat config.Format RemoveWhitespace bool @@ -2801,7 +2810,7 @@ func Print(tree ast.AST, options PrintOptions) PrintResult { QuotedSources: quotedSources(&tree, &options), EndState: p.prevState, FinalGeneratedColumn: len(p.js) - p.prevLineStart, - ShouldIgnore: len(p.sourceMap) == 0, + ShouldIgnore: p.shouldIgnoreSourceMap(), }, } } @@ -2819,7 +2828,7 @@ func PrintExpr(expr ast.Expr, symbols ast.SymbolMap, options PrintOptions) Print QuotedSources: quotedSources(nil, &options), EndState: p.prevState, FinalGeneratedColumn: len(p.js) - p.prevLineStart, - ShouldIgnore: len(p.sourceMap) == 0, + ShouldIgnore: p.shouldIgnoreSourceMap(), }, } } diff --git a/scripts/verify-source-map.js b/scripts/verify-source-map.js index 6034971252e..e871926ee05 100644 --- a/scripts/verify-source-map.js +++ b/scripts/verify-source-map.js @@ -142,7 +142,7 @@ async function check(kind, testCase, toSearch, flags) { } // Check the mapping of various key locations back to the original source - const checkMap = (out, map) => { + const checkMap = (out, map, relativeTo) => { for (const id of toSearch) { const inSource = isStdin ? '' : files.find(x => path.basename(x).startsWith(id[0])) const inJs = testCase[inSource] @@ -161,14 +161,14 @@ async function check(kind, testCase, toSearch, flags) { const outColumn = outLines[outLines.length - 1].length const { source, line, column } = map.originalPositionFor({ line: outLine, column: outColumn }) - const expected = JSON.stringify({ source: inSource, line: inLine, column: inColumn }) + const expected = JSON.stringify({ source: path.join(relativeTo, inSource), line: inLine, column: inColumn }) const observed = JSON.stringify({ source, line, column }) recordCheck(expected === observed, `expected: ${expected} observed: ${observed}`) } } const outMap = await new SourceMapConsumer(outJsMap) - checkMap(outJs, outMap) + checkMap(outJs, outMap, '') // Check that every generated location has an associated original position. // This only works when not bundling because bundling includes runtime code. @@ -199,14 +199,14 @@ async function check(kind, testCase, toSearch, flags) { order === 1 ? `import './${fileToTest}'; import './extra.js'` : order === 2 ? `import './extra.js'; import './${fileToTest}'` : `import './${fileToTest}'`) - await execFileAsync(esbuildPath, [nestedEntry, '--bundle', '--outfile=' + path.join(tempDir, 'out2.js'), '--sourcemap']) + await execFileAsync(esbuildPath, [nestedEntry, '--bundle', '--outfile=' + path.join(tempDir, 'out2.js'), '--sourcemap'], { cwd: testDir }) const out2Js = await readFileAsync(path.join(tempDir, 'out2.js'), 'utf8') recordCheck(out2Js.includes(`//# sourceMappingURL=out2.js.map\n`), `.js file links to .js.map`) const out2JsMap = await readFileAsync(path.join(tempDir, 'out2.js.map'), 'utf8') const out2Map = await new SourceMapConsumer(out2JsMap) - checkMap(out2Js, out2Map) + checkMap(out2Js, out2Map, path.relative(testDir, tempDir)) } if (!failed) rimraf.sync(tempDir, { disableGlob: true })