Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,20 @@ s.offset = 6
s.slice() === 'world'
```

## Hi-res mappings

The `hires` option (passed to `generateMap`/`generateDecodedMap`) controls how granular sourcemap mappings are:

- `false` (default) - lo-res mappings. Only one mapping per line, plus any locations added with `s.addSourcemapLocation()`. Quicker to generate and less bulky, but devtools may only be able to identify the correct line, not the exact column.
- `true` - hi-res mappings. Every single character gets a mapping, so devtools can always pinpoint the exact location of function calls and so on.
- `"boundary"` - semi-hi-res mappings, segmented per word boundary instead of per character. Suitable for string semantics that are separated by words.
- `"experimental-range"` - hi-res mappings that use [range mappings](https://github.com/tc39/ecma426/blob/main/proposals/range-mappings.md), a source map extension that can map all positions in a range with fewer mappings than mapping every character individually. This requires support for range mappings in the source map consumer, and the feature is experimental.

## Methods

### s.addSourcemapLocation( index )

Adds the specified character index (with respect to the original string) to sourcemap mappings, if `hires` is `false` (see below).
Adds the specified character index (with respect to the original string) to sourcemap mappings, if `hires` is `false` (see [Hi-res mappings](#hi-res-mappings)).

### s.append( content )

Expand Down Expand Up @@ -125,7 +134,7 @@ Generates a [version 3 sourcemap](https://docs.google.com/document/d/1U1RGAehQwR
- `file` - the filename where you plan to write the sourcemap
- `source` - the filename of the file containing the original source
- `includeContent` - whether to include the original content in the map's `sourcesContent` array
- `hires` - whether the mapping should be high-resolution. Hi-res mappings map every single character, meaning (for example) your devtools will always be able to pinpoint the exact location of function calls and so on. With lo-res mappings, devtools may only be able to identify the correct line - but they're quicker to generate and less bulky. You can also set `"boundary"` to generate a semi-hi-res mappings segmented per word boundary instead of per character, suitable for string semantics that are separated by words. If sourcemap locations have been specified with `s.addSourcemapLocation()`, they will be used here.
- `hires` - whether the mapping should be high-resolution. See [Hi-res mappings](#hi-res-mappings) for the available modes. If sourcemap locations have been specified with `s.addSourcemapLocation()`, they will be used here.

The returned sourcemap has two (non-enumerable) methods attached for convenience:

Expand Down
1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
minimumReleaseAgeExcludePrune: true
minimumReleaseAgeExclude:
- tsdown
- '@types/node@26.5.1'

trustPolicy: no-downgrade
Expand Down
1 change: 1 addition & 0 deletions src/Bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export default class Bundle {
names,
mappings: mappings.raw,
x_google_ignoreList,
rangeMappings: mappings.rawRangeMappings,
}
}

Expand Down
1 change: 1 addition & 0 deletions src/MagicString.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ export default class MagicString {
names,
mappings: mappings.raw,
x_google_ignoreList: this.ignoreList ? [sourceIndex] : undefined,
rangeMappings: mappings.rawRangeMappings,
}
}

Expand Down
41 changes: 32 additions & 9 deletions src/SourceMap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { encode } from '@jridgewell/sourcemap-codec'
import { encode, encodeRangeMappings } from '@jridgewell/sourcemap-codec'
import MagicStringError from './MagicStringError.ts'

type Btoa = (str: string) => string
Expand All @@ -11,16 +11,23 @@ interface GlobalBuffer {

export interface SourceMapOptions {
/**
* Whether the mapping should be high-resolution.
* Hi-res mappings map every single character, meaning (for example) your devtools will always
* be able to pinpoint the exact location of function calls and so on.
* With lo-res mappings, devtools may only be able to identify the correct
* line - but they're quicker to generate and less bulky.
* You can also set `"boundary"` to generate a semi-hi-res mappings segmented per word boundary
* instead of per character, suitable for string semantics that are separated by words.
* Whether the mapping should be high-resolution:
* - `false` (default) - lo-res mappings. Only one mapping per line, plus any locations added
* with `s.addSourcemapLocation()`. Quicker to generate and less bulky, but devtools may only
* be able to identify the correct line, not the exact column.
* - `true` - hi-res mappings. Every single character gets a mapping, so devtools can always
* pinpoint the exact location of function calls and so on.
* - `"boundary"` - semi-hi-res mappings, segmented per word boundary instead of per character.
* Suitable for string semantics that are separated by words.
* - `"experimental-range"` - hi-res mappings that use range mappings
* (https://github.com/tc39/ecma426/blob/main/proposals/range-mappings.md), a source map
* extension that can map all positions in a range with fewer mappings than mapping every
* character individually. This requires support for range mappings in the source map
* consumer, and the feature is experimental.
*
* If sourcemap locations have been specified with s.addSourceMapLocation(), they will be used here.
*/
hires?: boolean | 'boundary'
hires?: boolean | 'boundary' | 'experimental-range'
/**
* The filename where you plan to write the sourcemap.
*/
Expand All @@ -40,6 +47,8 @@ export type SourceMapSegment
| [number, number, number, number]
| [number, number, number, number, number]

export type SourceMapRangeMappings = number[][]

export interface DecodedSourceMap {
file?: string
sources: string[]
Expand All @@ -48,6 +57,7 @@ export interface DecodedSourceMap {
mappings: SourceMapSegment[][]
x_google_ignoreList?: number[]
debugId?: string
rangeMappings?: SourceMapRangeMappings
}

function getBtoa(): Btoa {
Expand Down Expand Up @@ -77,6 +87,7 @@ export default class SourceMap {
declare mappings: string
declare x_google_ignoreList: number[] | undefined
declare debugId: string | undefined
declare rangeMappings: string | undefined

constructor(properties: DecodedSourceMap) {
this.version = 3
Expand All @@ -91,6 +102,18 @@ export default class SourceMap {
if (typeof properties.debugId !== 'undefined') {
this.debugId = properties.debugId
}
if (typeof properties.rangeMappings !== 'undefined') {
let shouldOutputRangeMapping = false
for (const line of properties.rangeMappings) {
if (line.length !== 0) {
shouldOutputRangeMapping = true
break
}
}
if (shouldOutputRangeMapping) {
this.rangeMappings = encodeRangeMappings(properties.rangeMappings)
}
}
}

/**
Expand Down
18 changes: 17 additions & 1 deletion src/utils/Mappings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type BitSet from '../BitSet.ts'
import type Chunk from '../Chunk.ts'
import type { SourceMapOptions, SourceMapSegment } from '../SourceMap.ts'
import type { SourceMapOptions, SourceMapRangeMappings, SourceMapSegment } from '../SourceMap.ts'
import type { SourceLocation } from './getLocator.ts'

const wordRegex = /\w/
Expand All @@ -11,6 +11,8 @@ export default class Mappings {
declare generatedCodeColumn: number
declare raw: SourceMapSegment[][]
declare rawSegments: SourceMapSegment[]
declare rawRangeMappings: SourceMapRangeMappings
declare rawRangeMappingsIndices: number[]
declare pending: SourceMapSegment | null

constructor(hires: SourceMapOptions['hires']) {
Expand All @@ -19,6 +21,8 @@ export default class Mappings {
this.generatedCodeColumn = 0
this.raw = []
this.rawSegments = this.raw[this.generatedCodeLine] = []
this.rawRangeMappings = []
this.rawRangeMappingsIndices = this.rawRangeMappings[this.generatedCodeLine] = []
this.pending = null
}

Expand All @@ -43,6 +47,7 @@ export default class Mappings {

this.generatedCodeLine += 1
this.raw[this.generatedCodeLine] = this.rawSegments = []
this.rawRangeMappings[this.generatedCodeLine] = this.rawRangeMappingsIndices = []
this.generatedCodeColumn = 0

previousContentLineEnd = contentLineEnd
Expand Down Expand Up @@ -83,11 +88,15 @@ export default class Mappings {
let charInHiresBoundary = false

while (originalCharIndex < chunk.end) {
if (this.hires === 'experimental-range' && originalCharIndex + 1 >= chunk.end) {
this.rawSegments.push([this.generatedCodeColumn, sourceIndex, loc.line, loc.column])
}
if (original[originalCharIndex] === '\n') {
loc.line += 1
loc.column = 0
this.generatedCodeLine += 1
this.raw[this.generatedCodeLine] = this.rawSegments = []
this.rawRangeMappings[this.generatedCodeLine] = this.rawRangeMappingsIndices = []
this.generatedCodeColumn = 0
first = true
charInHiresBoundary = false
Expand Down Expand Up @@ -116,6 +125,12 @@ export default class Mappings {
charInHiresBoundary = false
}
}
else if (this.hires === 'experimental-range') {
if (originalCharIndex === chunk.start) {
this.rawRangeMappingsIndices.push(this.rawSegments.length)
this.rawSegments.push(segment)
}
}
else {
this.rawSegments.push(segment)
}
Expand All @@ -142,6 +157,7 @@ export default class Mappings {
for (let i = 0; i < lines.length - 1; i++) {
this.generatedCodeLine++
this.raw[this.generatedCodeLine] = this.rawSegments = []
this.rawRangeMappings[this.generatedCodeLine] = this.rawRangeMappingsIndices = []
}
this.generatedCodeColumn = 0
}
Expand Down
103 changes: 103 additions & 0 deletions test/Bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,109 @@ describe('bundle', () => {
])
})

it('numbers hires "experimental-range" mappings across sources sharing a generated line', () => {
const b = new Bundle({ separator: ' + ' })

b.addSource(new MagicString('aaa', { filename: 'a.js' }))
b.addSource(new MagicString('bbb', { filename: 'b.js' }))
assert.equal(b.toString(), 'aaa + bbb')

const decoded = b.generateDecodedMap({ hires: 'experimental-range' })

assert.deepEqual(decoded.mappings, [
[
[0, 0, 0, 0],
[2, 0, 0, 2],
[6, 1, 0, 0],
[8, 1, 0, 2],
],
])

assert.deepEqual(decoded.rangeMappings, [[0, 2]])

const map = b.generateMap({ hires: 'experimental-range' })
assert.equal(map.mappings, 'AAAA,EAAE,ICAF,EAAE')
assert.equal(map.rangeMappings, 'AC')
})

it('records hires "experimental-range" mappings per generated line', () => {
const b = new Bundle()

b.addSource(new MagicString('aaa', { filename: 'a.js' }))
b.addSource(new MagicString('bbb', { filename: 'b.js' }))
assert.equal(b.toString(), 'aaa\nbbb')

const decoded = b.generateDecodedMap({ hires: 'experimental-range' })

assert.deepEqual(decoded.mappings, [
[[0, 0, 0, 0], [2, 0, 0, 2]],
[[0, 1, 0, 0], [2, 1, 0, 2]],
])
assert.deepEqual(decoded.rangeMappings, [[0], [0]])

const map = b.generateMap({ hires: 'experimental-range' })
assert.equal(map.mappings, 'AAAA,EAAE;ACAF,EAAE')
assert.equal(map.rangeMappings, 'A;A')
})

it('tracks generated lines across a source containing a new line and a new line separator', () => {
const b = new Bundle()

b.addSource(new MagicString('aaa\nbbb', { filename: 'a.js' }))
b.addSource(new MagicString('ccc', { filename: 'b.js' }))
assert.equal(b.toString(), 'aaa\nbbb\nccc')

const decoded = b.generateDecodedMap({ hires: 'experimental-range' })

assert.deepEqual(decoded.mappings, [
[[0, 0, 0, 0]],
[[2, 0, 1, 2]],
[[0, 1, 0, 0], [2, 1, 0, 2]],
])

assert.deepEqual(decoded.rangeMappings, [[0], [], [0]])

const map = b.generateMap({ hires: 'experimental-range' })
assert.equal(map.mappings, 'AAAA;EACE;ACDF,EAAE')
assert.equal(map.rangeMappings, 'A;;A')
})

it('does not record hires "experimental-range" mappings for sources without a filename', () => {
const b = new Bundle({ separator: '' })

b.addSource({ content: new MagicString('/*x*/') })
b.addSource(new MagicString('aaa', { filename: 'a.js' }))
assert.equal(b.toString(), '/*x*/aaa')

const decoded = b.generateDecodedMap({ hires: 'experimental-range' })

assert.deepEqual(decoded.mappings, [
[
[5, 0, 0, 0],
[7, 0, 0, 2],
],
])
assert.deepEqual(decoded.rangeMappings, [[0]])

const map = b.generateMap({ hires: 'experimental-range' })
assert.equal(map.mappings, 'KAAA,EAAE')
assert.equal(map.rangeMappings, 'A')
})

it('does not emit rangeMappings for other hires modes', () => {
const b = new Bundle()

b.addSource(new MagicString('aaa', { filename: 'a.js' }))

assert.deepEqual(b.generateDecodedMap().rangeMappings, [[]])
assert.deepEqual(b.generateDecodedMap({ hires: true }).rangeMappings, [[]])
assert.deepEqual(b.generateDecodedMap({ hires: 'boundary' }).rangeMappings, [[]])

assert.equal(b.generateMap().rangeMappings, undefined)
assert.equal(b.generateMap({ hires: true }).rangeMappings, undefined)
assert.equal(b.generateMap({ hires: 'boundary' }).rangeMappings, undefined)
})

it('handles prepended content', () => {
const b = new Bundle()

Expand Down
Loading