@jsenv/urls 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/url_meta/advanced.md +28 -0
- package/docs/url_meta/demo/apply_associations.mjs +27 -0
- package/docs/url_meta/demo/full_match.mjs +7 -0
- package/docs/url_meta/demo/partial_match.mjs +7 -0
- package/docs/url_meta/demo/resolve_associations.mjs +12 -0
- package/docs/url_meta/notes.md +26 -0
- package/docs/url_meta/pattern_matching.md +71 -0
- package/docs/url_meta/readme.md +87 -0
- package/main.js +27 -0
- package/package.json +28 -0
- package/src/caller_position.js +20 -0
- package/src/data_url.js +50 -0
- package/src/inline_content_url_generator.js +21 -0
- package/src/meta/aliases.js +28 -0
- package/src/meta/as_flat_associations.js +28 -0
- package/src/meta/assertions.js +57 -0
- package/src/meta/associations.js +25 -0
- package/src/meta/pattern_matching.js +245 -0
- package/src/meta/resolve_associations.js +25 -0
- package/src/meta/url_child_may_match.js +47 -0
- package/src/meta/url_meta.js +13 -0
- package/src/url_trace.js +229 -0
- package/src/url_utils.js +107 -0
- package/src/windows_file_path_utils.js +16 -0
- package/test/data_url.test.mjs +57 -0
- package/test/meta/aliasing.test.mjs +47 -0
- package/test/meta/as_flat_associations/basic.test.mjs +64 -0
- package/test/meta/assertions.test.mjs +23 -0
- package/test/meta/get_url_meta/any.test.mjs +94 -0
- package/test/meta/get_url_meta/basic.test.mjs +226 -0
- package/test/meta/get_url_meta/complex.test.mjs +203 -0
- package/test/meta/get_url_meta/double_wildcard_after.test.mjs +68 -0
- package/test/meta/get_url_meta/double_wildcard_around.test.mjs +42 -0
- package/test/meta/get_url_meta/double_wildcard_before.test.mjs +55 -0
- package/test/meta/get_url_meta/inheritance.test.mjs +59 -0
- package/test/meta/is_plain_object.test.mjs +27 -0
- package/test/meta/pattern_matching/basic.test.mjs +100 -0
- package/test/meta/pattern_matching/common.test.mjs +145 -0
- package/test/meta/pattern_matching/complex.test.mjs +159 -0
- package/test/meta/pattern_matching/contains.test.mjs +46 -0
- package/test/meta/pattern_matching/doublewildcard_after.test.mjs +81 -0
- package/test/meta/pattern_matching/doublewildcard_around.test.mjs +42 -0
- package/test/meta/pattern_matching/doublewildcard_before.test.mjs +68 -0
- package/test/meta/pattern_matching/doublewildcard_inside.test.mjs +42 -0
- package/test/meta/pattern_matching/trailing_slash.test.mjs +55 -0
- package/test/meta/pattern_matching/wildcard_after.test.mjs +68 -0
- package/test/meta/pattern_matching/wildcard_around.test.mjs +94 -0
- package/test/meta/pattern_matching/wildcard_before.test.mjs +55 -0
- package/test/meta/pattern_matching/wildcard_inside.test.mjs +68 -0
- package/test/meta/resolve_associations/basic.test.mjs +42 -0
- package/test/meta/resolve_associations/http_pattern.test.mjs +35 -0
- package/test/meta/url_child_may_match/inheritance.test.mjs +71 -0
- package/test/meta/url_child_may_match/url_child_may_match.test.mjs +166 -0
- package/test/url_trace.test.mjs +224 -0
- package/test.mjs +13 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Link to things doing pattern matching:
|
|
3
|
+
* https://git-scm.com/docs/gitignore
|
|
4
|
+
* https://github.com/kaelzhang/node-ignore
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { assertUrlLike } from "./assertions.js"
|
|
8
|
+
|
|
9
|
+
/** @module jsenv_url_meta **/
|
|
10
|
+
/**
|
|
11
|
+
* An object representing the result of applying a pattern to an url
|
|
12
|
+
* @typedef {Object} MatchResult
|
|
13
|
+
* @property {boolean} matched Indicates if url matched pattern
|
|
14
|
+
* @property {number} patternIndex Index where pattern stopped matching url, otherwise pattern.length
|
|
15
|
+
* @property {number} urlIndex Index where url stopped matching pattern, otherwise url.length
|
|
16
|
+
* @property {Array} matchGroups Array of strings captured during pattern matching
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Apply a pattern to an url
|
|
21
|
+
* @param {Object} applyPatternMatchingParams
|
|
22
|
+
* @param {string} applyPatternMatchingParams.pattern "*", "**" and trailing slash have special meaning
|
|
23
|
+
* @param {string} applyPatternMatchingParams.url a string representing an url
|
|
24
|
+
* @return {MatchResult}
|
|
25
|
+
*/
|
|
26
|
+
export const applyPatternMatching = ({ url, pattern }) => {
|
|
27
|
+
assertUrlLike(pattern, "pattern")
|
|
28
|
+
assertUrlLike(url, "url")
|
|
29
|
+
const { matched, patternIndex, index, groups } = applyMatching(pattern, url)
|
|
30
|
+
const matchGroups = []
|
|
31
|
+
let groupIndex = 0
|
|
32
|
+
groups.forEach((group) => {
|
|
33
|
+
if (group.name) {
|
|
34
|
+
matchGroups[group.name] = group.string
|
|
35
|
+
} else {
|
|
36
|
+
matchGroups[groupIndex] = group.string
|
|
37
|
+
groupIndex++
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
return {
|
|
41
|
+
matched,
|
|
42
|
+
patternIndex,
|
|
43
|
+
urlIndex: index,
|
|
44
|
+
matchGroups,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const applyMatching = (pattern, string) => {
|
|
49
|
+
const groups = []
|
|
50
|
+
let patternIndex = 0
|
|
51
|
+
let index = 0
|
|
52
|
+
let remainingPattern = pattern
|
|
53
|
+
let remainingString = string
|
|
54
|
+
let restoreIndexes = true
|
|
55
|
+
|
|
56
|
+
const consumePattern = (count) => {
|
|
57
|
+
const subpattern = remainingPattern.slice(0, count)
|
|
58
|
+
remainingPattern = remainingPattern.slice(count)
|
|
59
|
+
patternIndex += count
|
|
60
|
+
return subpattern
|
|
61
|
+
}
|
|
62
|
+
const consumeString = (count) => {
|
|
63
|
+
const substring = remainingString.slice(0, count)
|
|
64
|
+
remainingString = remainingString.slice(count)
|
|
65
|
+
index += count
|
|
66
|
+
return substring
|
|
67
|
+
}
|
|
68
|
+
const consumeRemainingString = () => {
|
|
69
|
+
return consumeString(remainingString.length)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let matched
|
|
73
|
+
const iterate = () => {
|
|
74
|
+
const patternIndexBefore = patternIndex
|
|
75
|
+
const indexBefore = index
|
|
76
|
+
matched = matchOne()
|
|
77
|
+
if (matched === undefined) {
|
|
78
|
+
consumePattern(1)
|
|
79
|
+
consumeString(1)
|
|
80
|
+
iterate()
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
if (matched === false && restoreIndexes) {
|
|
84
|
+
patternIndex = patternIndexBefore
|
|
85
|
+
index = indexBefore
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const matchOne = () => {
|
|
89
|
+
// pattern consumed and string consumed
|
|
90
|
+
if (remainingPattern === "" && remainingString === "") {
|
|
91
|
+
return true // string fully matched pattern
|
|
92
|
+
}
|
|
93
|
+
// pattern consumed, string not consumed
|
|
94
|
+
if (remainingPattern === "" && remainingString !== "") {
|
|
95
|
+
return false // fails because string longer than expected
|
|
96
|
+
}
|
|
97
|
+
// -- from this point pattern is not consumed --
|
|
98
|
+
// string consumed, pattern not consumed
|
|
99
|
+
if (remainingString === "") {
|
|
100
|
+
if (remainingPattern === "**") {
|
|
101
|
+
// trailing "**" is optional
|
|
102
|
+
consumePattern(2)
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
if (remainingPattern === "*") {
|
|
106
|
+
groups.push({ string: "" })
|
|
107
|
+
}
|
|
108
|
+
return false // fail because string shorter than expected
|
|
109
|
+
}
|
|
110
|
+
// -- from this point pattern and string are not consumed --
|
|
111
|
+
// fast path trailing slash
|
|
112
|
+
if (remainingPattern === "/") {
|
|
113
|
+
if (remainingString[0] === "/") {
|
|
114
|
+
// trailing slash match remaining
|
|
115
|
+
consumePattern(1)
|
|
116
|
+
groups.push({ string: consumeRemainingString() })
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
// fast path trailing '**'
|
|
122
|
+
if (remainingPattern === "**") {
|
|
123
|
+
consumePattern(2)
|
|
124
|
+
consumeRemainingString()
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
// pattern leading **
|
|
128
|
+
if (remainingPattern.slice(0, 2) === "**") {
|
|
129
|
+
consumePattern(2) // consumes "**"
|
|
130
|
+
if (remainingPattern[0] === "/") {
|
|
131
|
+
consumePattern(1) // consumes "/"
|
|
132
|
+
}
|
|
133
|
+
// pattern ending with ** always match remaining string
|
|
134
|
+
if (remainingPattern === "") {
|
|
135
|
+
consumeRemainingString()
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
const skipResult = skipUntilMatch({
|
|
139
|
+
pattern: remainingPattern,
|
|
140
|
+
string: remainingString,
|
|
141
|
+
canSkipSlash: true,
|
|
142
|
+
})
|
|
143
|
+
groups.push(...skipResult.groups)
|
|
144
|
+
consumePattern(skipResult.patternIndex)
|
|
145
|
+
consumeRemainingString()
|
|
146
|
+
restoreIndexes = false
|
|
147
|
+
return skipResult.matched
|
|
148
|
+
}
|
|
149
|
+
if (remainingPattern[0] === "*") {
|
|
150
|
+
consumePattern(1) // consumes "*"
|
|
151
|
+
if (remainingPattern === "") {
|
|
152
|
+
// matches everything except '/'
|
|
153
|
+
const slashIndex = remainingString.indexOf("/")
|
|
154
|
+
if (slashIndex === -1) {
|
|
155
|
+
groups.push({ string: consumeRemainingString() })
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
groups.push({ string: consumeString(slashIndex) })
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
// the next char must not the one expected by remainingPattern[0]
|
|
162
|
+
// because * is greedy and expect to skip at least one char
|
|
163
|
+
if (remainingPattern[0] === remainingString[0]) {
|
|
164
|
+
groups.push({ string: "" })
|
|
165
|
+
patternIndex = patternIndex - 1
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
const skipResult = skipUntilMatch({
|
|
169
|
+
pattern: remainingPattern,
|
|
170
|
+
string: remainingString,
|
|
171
|
+
canSkipSlash: false,
|
|
172
|
+
})
|
|
173
|
+
groups.push(skipResult.group, ...skipResult.groups)
|
|
174
|
+
consumePattern(skipResult.patternIndex)
|
|
175
|
+
consumeString(skipResult.index)
|
|
176
|
+
restoreIndexes = false
|
|
177
|
+
return skipResult.matched
|
|
178
|
+
}
|
|
179
|
+
if (remainingPattern[0] !== remainingString[0]) {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
return undefined
|
|
183
|
+
}
|
|
184
|
+
iterate()
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
matched,
|
|
188
|
+
patternIndex,
|
|
189
|
+
index,
|
|
190
|
+
groups,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const skipUntilMatch = ({ pattern, string, canSkipSlash }) => {
|
|
195
|
+
let index = 0
|
|
196
|
+
let remainingString = string
|
|
197
|
+
let longestMatchRange = null
|
|
198
|
+
const tryToMatch = () => {
|
|
199
|
+
const matchAttempt = applyMatching(pattern, remainingString)
|
|
200
|
+
if (matchAttempt.matched) {
|
|
201
|
+
return {
|
|
202
|
+
matched: true,
|
|
203
|
+
patternIndex: matchAttempt.patternIndex,
|
|
204
|
+
index: index + matchAttempt.index,
|
|
205
|
+
groups: matchAttempt.groups,
|
|
206
|
+
group: {
|
|
207
|
+
string:
|
|
208
|
+
remainingString === ""
|
|
209
|
+
? string
|
|
210
|
+
: string.slice(0, -remainingString.length),
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const matchAttemptIndex = matchAttempt.index
|
|
215
|
+
const matchRange = {
|
|
216
|
+
patternIndex: matchAttempt.patternIndex,
|
|
217
|
+
index,
|
|
218
|
+
length: matchAttemptIndex,
|
|
219
|
+
groups: matchAttempt.groups,
|
|
220
|
+
}
|
|
221
|
+
if (!longestMatchRange || longestMatchRange.length < matchRange.length) {
|
|
222
|
+
longestMatchRange = matchRange
|
|
223
|
+
}
|
|
224
|
+
const nextIndex = matchAttemptIndex + 1
|
|
225
|
+
const canSkip =
|
|
226
|
+
nextIndex < remainingString.length &&
|
|
227
|
+
(canSkipSlash || remainingString[0] !== "/")
|
|
228
|
+
if (canSkip) {
|
|
229
|
+
// search against the next unattempted string
|
|
230
|
+
index += nextIndex
|
|
231
|
+
remainingString = remainingString.slice(nextIndex)
|
|
232
|
+
return tryToMatch()
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
matched: false,
|
|
236
|
+
patternIndex: longestMatchRange.patternIndex,
|
|
237
|
+
index: longestMatchRange.index + longestMatchRange.length,
|
|
238
|
+
groups: longestMatchRange.groups,
|
|
239
|
+
group: {
|
|
240
|
+
string: string.slice(0, longestMatchRange.index),
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return tryToMatch()
|
|
245
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { assertUrlLike } from "./assertions.js"
|
|
2
|
+
|
|
3
|
+
export const resolveAssociations = (associations, baseUrl) => {
|
|
4
|
+
assertUrlLike(baseUrl, "baseUrl")
|
|
5
|
+
const associationsResolved = {}
|
|
6
|
+
Object.keys(associations).forEach((key) => {
|
|
7
|
+
const valueMap = associations[key]
|
|
8
|
+
const valueMapResolved = {}
|
|
9
|
+
Object.keys(valueMap).forEach((pattern) => {
|
|
10
|
+
const value = valueMap[pattern]
|
|
11
|
+
const patternResolved = normalizeUrlPattern(pattern, baseUrl)
|
|
12
|
+
valueMapResolved[patternResolved] = value
|
|
13
|
+
})
|
|
14
|
+
associationsResolved[key] = valueMapResolved
|
|
15
|
+
})
|
|
16
|
+
return associationsResolved
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const normalizeUrlPattern = (urlPattern, baseUrl) => {
|
|
20
|
+
// starts with a scheme
|
|
21
|
+
if (/^[a-zA-Z]{2,}:/.test(urlPattern)) {
|
|
22
|
+
return urlPattern
|
|
23
|
+
}
|
|
24
|
+
return String(new URL(urlPattern, baseUrl))
|
|
25
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { assertUrlLike, isPlainObject } from "./assertions.js"
|
|
2
|
+
import { asFlatAssociations } from "./as_flat_associations.js"
|
|
3
|
+
import { applyPatternMatching } from "./pattern_matching.js"
|
|
4
|
+
|
|
5
|
+
export const urlChildMayMatch = ({ url, associations, predicate }) => {
|
|
6
|
+
assertUrlLike(url, "url")
|
|
7
|
+
// the function was meants to be used on url ending with '/'
|
|
8
|
+
if (!url.endsWith("/")) {
|
|
9
|
+
throw new Error(`url should end with /, got ${url}`)
|
|
10
|
+
}
|
|
11
|
+
if (typeof predicate !== "function") {
|
|
12
|
+
throw new TypeError(`predicate must be a function, got ${predicate}`)
|
|
13
|
+
}
|
|
14
|
+
const flatAssociations = asFlatAssociations(associations)
|
|
15
|
+
// for full match we must create an object to allow pattern to override previous ones
|
|
16
|
+
let fullMatchMeta = {}
|
|
17
|
+
let someFullMatch = false
|
|
18
|
+
// for partial match, any meta satisfying predicate will be valid because
|
|
19
|
+
// we don't know for sure if pattern will still match for a file inside pathname
|
|
20
|
+
const partialMatchMetaArray = []
|
|
21
|
+
Object.keys(flatAssociations).forEach((pattern) => {
|
|
22
|
+
const value = flatAssociations[pattern]
|
|
23
|
+
const matchResult = applyPatternMatching({
|
|
24
|
+
pattern,
|
|
25
|
+
url,
|
|
26
|
+
})
|
|
27
|
+
if (matchResult.matched) {
|
|
28
|
+
someFullMatch = true
|
|
29
|
+
if (isPlainObject(fullMatchMeta) && isPlainObject(value)) {
|
|
30
|
+
fullMatchMeta = {
|
|
31
|
+
...fullMatchMeta,
|
|
32
|
+
...value,
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
fullMatchMeta = value
|
|
36
|
+
}
|
|
37
|
+
} else if (someFullMatch === false && matchResult.urlIndex >= url.length) {
|
|
38
|
+
partialMatchMetaArray.push(value)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
if (someFullMatch) {
|
|
42
|
+
return Boolean(predicate(fullMatchMeta))
|
|
43
|
+
}
|
|
44
|
+
return partialMatchMetaArray.some((partialMatchMeta) =>
|
|
45
|
+
predicate(partialMatchMeta),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { resolveAssociations } from "./resolve_associations.js"
|
|
2
|
+
import { applyAssociations } from "./associations.js"
|
|
3
|
+
import { applyAliases } from "./aliases.js"
|
|
4
|
+
import { applyPatternMatching } from "./pattern_matching.js"
|
|
5
|
+
import { urlChildMayMatch } from "./url_child_may_match.js"
|
|
6
|
+
|
|
7
|
+
export const URL_META = {
|
|
8
|
+
resolveAssociations,
|
|
9
|
+
applyAssociations,
|
|
10
|
+
urlChildMayMatch,
|
|
11
|
+
applyPatternMatching,
|
|
12
|
+
applyAliases,
|
|
13
|
+
}
|
package/src/url_trace.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// consider switching to https://babeljs.io/docs/en/babel-code-frame
|
|
2
|
+
// https://github.com/postcss/postcss/blob/fd30d3df5abc0954a0ec642a3cdc644ab2aacf9c/lib/css-syntax-error.js#L43
|
|
3
|
+
// https://github.com/postcss/postcss/blob/fd30d3df5abc0954a0ec642a3cdc644ab2aacf9c/lib/terminal-highlight.js#L50
|
|
4
|
+
// https://github.com/babel/babel/blob/eea156b2cb8deecfcf82d52aa1b71ba4995c7d68/packages/babel-code-frame/src/index.js#L1
|
|
5
|
+
|
|
6
|
+
import { urlToFileSystemPath } from "@jsenv/filesystem"
|
|
7
|
+
import { ANSI } from "@jsenv/log"
|
|
8
|
+
|
|
9
|
+
export const stringifyUrlTrace = (trace) => {
|
|
10
|
+
let string = ""
|
|
11
|
+
trace.forEach(({ type, url, line, column, source }, index) => {
|
|
12
|
+
if (index === 0) {
|
|
13
|
+
string += stringifyUrlSite({ url, line, column, source })
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (type === "import") {
|
|
18
|
+
string += `\n imported by ${stringifyUrlSite({
|
|
19
|
+
url,
|
|
20
|
+
line,
|
|
21
|
+
column,
|
|
22
|
+
})}`
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
string += `\n referenced by ${stringifyUrlSite({
|
|
27
|
+
url,
|
|
28
|
+
line,
|
|
29
|
+
column,
|
|
30
|
+
})}`
|
|
31
|
+
}, "")
|
|
32
|
+
|
|
33
|
+
return string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const stringifyUrlSite = (
|
|
37
|
+
{ url, line, column, content },
|
|
38
|
+
{
|
|
39
|
+
showCodeFrame = true,
|
|
40
|
+
numberOfSurroundingLinesToShow,
|
|
41
|
+
lineMaxLength,
|
|
42
|
+
color,
|
|
43
|
+
} = {},
|
|
44
|
+
) => {
|
|
45
|
+
let string = `${humanizeUrl(url)}`
|
|
46
|
+
|
|
47
|
+
if (typeof line === "number") {
|
|
48
|
+
string += `:${line}`
|
|
49
|
+
if (typeof column === "number") {
|
|
50
|
+
string += `:${column}`
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!showCodeFrame || typeof line !== "number" || !content) {
|
|
55
|
+
return string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sourceLoc = showSourceLocation({
|
|
59
|
+
content,
|
|
60
|
+
line,
|
|
61
|
+
column,
|
|
62
|
+
numberOfSurroundingLinesToShow,
|
|
63
|
+
lineMaxLength,
|
|
64
|
+
color,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return `${string}
|
|
68
|
+
${sourceLoc}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const humanizeUrl = (url) => {
|
|
72
|
+
if (url.startsWith("file://")) {
|
|
73
|
+
// we prefer file system path because vscode reliably make them clickable
|
|
74
|
+
// and sometimes it won't for file:// urls
|
|
75
|
+
return urlToFileSystemPath(url)
|
|
76
|
+
}
|
|
77
|
+
return url
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const showSourceLocation = ({
|
|
81
|
+
content,
|
|
82
|
+
line,
|
|
83
|
+
column,
|
|
84
|
+
numberOfSurroundingLinesToShow = 1,
|
|
85
|
+
lineMaxLength = 120,
|
|
86
|
+
color = false,
|
|
87
|
+
} = {}) => {
|
|
88
|
+
let mark = (string) => string
|
|
89
|
+
let aside = (string) => string
|
|
90
|
+
if (color) {
|
|
91
|
+
mark = (string) => ANSI.color(string, ANSI.RED)
|
|
92
|
+
aside = (string) => ANSI.color(string, ANSI.GREY)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const lines = content.split(/\r?\n/)
|
|
96
|
+
if (line === 0) line = 1
|
|
97
|
+
let lineRange = {
|
|
98
|
+
start: line - 1,
|
|
99
|
+
end: line,
|
|
100
|
+
}
|
|
101
|
+
lineRange = moveLineRangeUp(lineRange, numberOfSurroundingLinesToShow)
|
|
102
|
+
lineRange = moveLineRangeDown(lineRange, numberOfSurroundingLinesToShow)
|
|
103
|
+
lineRange = lineRangeWithinLines(lineRange, lines)
|
|
104
|
+
const linesToShow = lines.slice(lineRange.start, lineRange.end)
|
|
105
|
+
const endLineNumber = lineRange.end
|
|
106
|
+
const lineNumberMaxWidth = String(endLineNumber).length
|
|
107
|
+
|
|
108
|
+
if (column === 0) column = 1
|
|
109
|
+
|
|
110
|
+
const columnRange = {}
|
|
111
|
+
if (column === undefined) {
|
|
112
|
+
columnRange.start = 0
|
|
113
|
+
columnRange.end = lineMaxLength
|
|
114
|
+
} else if (column > lineMaxLength) {
|
|
115
|
+
columnRange.start = column - Math.floor(lineMaxLength / 2)
|
|
116
|
+
columnRange.end = column + Math.ceil(lineMaxLength / 2)
|
|
117
|
+
} else {
|
|
118
|
+
columnRange.start = 0
|
|
119
|
+
columnRange.end = lineMaxLength
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return linesToShow.map((lineSource, index) => {
|
|
123
|
+
const lineNumber = lineRange.start + index + 1
|
|
124
|
+
const isMainLine = lineNumber === line
|
|
125
|
+
const lineSourceTruncated = applyColumnRange(columnRange, lineSource)
|
|
126
|
+
const lineNumberWidth = String(lineNumber).length
|
|
127
|
+
// ensure if line moves from 7,8,9 to 10 the display is still great
|
|
128
|
+
const lineNumberRightSpacing = " ".repeat(
|
|
129
|
+
lineNumberMaxWidth - lineNumberWidth,
|
|
130
|
+
)
|
|
131
|
+
const asideSource = `${lineNumber}${lineNumberRightSpacing} |`
|
|
132
|
+
const lineFormatted = `${aside(asideSource)} ${lineSourceTruncated}`
|
|
133
|
+
if (isMainLine) {
|
|
134
|
+
if (column === undefined) {
|
|
135
|
+
return `${mark(">")} ${lineFormatted}`
|
|
136
|
+
}
|
|
137
|
+
const spacing = stringToSpaces(
|
|
138
|
+
`${asideSource} ${lineSourceTruncated.slice(
|
|
139
|
+
0,
|
|
140
|
+
column - columnRange.start - 1,
|
|
141
|
+
)}`,
|
|
142
|
+
)
|
|
143
|
+
return `${mark(">")} ${lineFormatted}
|
|
144
|
+
${spacing}${mark("^")}`
|
|
145
|
+
}
|
|
146
|
+
return ` ${lineFormatted}`
|
|
147
|
+
}).join(`
|
|
148
|
+
`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const applyColumnRange = ({ start, end }, line) => {
|
|
152
|
+
if (typeof start !== "number") {
|
|
153
|
+
throw new TypeError(`start must be a number, received ${start}`)
|
|
154
|
+
}
|
|
155
|
+
if (typeof end !== "number") {
|
|
156
|
+
throw new TypeError(`end must be a number, received ${end}`)
|
|
157
|
+
}
|
|
158
|
+
if (end < start) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`end must be greater than start, but ${end} is smaller than ${start}`,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const prefix = "…"
|
|
165
|
+
const suffix = "…"
|
|
166
|
+
const lastIndex = line.length
|
|
167
|
+
|
|
168
|
+
if (line.length === 0) {
|
|
169
|
+
// don't show any ellipsis if the line is empty
|
|
170
|
+
// because it's not truncated in that case
|
|
171
|
+
return ""
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const startTruncated = start > 0
|
|
175
|
+
const endTruncated = lastIndex > end
|
|
176
|
+
|
|
177
|
+
let from = startTruncated ? start + prefix.length : start
|
|
178
|
+
let to = endTruncated ? end - suffix.length : end
|
|
179
|
+
if (to > lastIndex) to = lastIndex
|
|
180
|
+
|
|
181
|
+
if (start >= lastIndex || from === to) {
|
|
182
|
+
return ""
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let result = ""
|
|
186
|
+
while (from < to) {
|
|
187
|
+
result += line[from]
|
|
188
|
+
from++
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (result.length === 0) {
|
|
192
|
+
return ""
|
|
193
|
+
}
|
|
194
|
+
if (startTruncated && endTruncated) {
|
|
195
|
+
return `${prefix}${result}${suffix}`
|
|
196
|
+
}
|
|
197
|
+
if (startTruncated) {
|
|
198
|
+
return `${prefix}${result}`
|
|
199
|
+
}
|
|
200
|
+
if (endTruncated) {
|
|
201
|
+
return `${result}${suffix}`
|
|
202
|
+
}
|
|
203
|
+
return result
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const stringToSpaces = (string) => string.replace(/[^\t]/g, " ")
|
|
207
|
+
|
|
208
|
+
// const getLineRangeLength = ({ start, end }) => end - start
|
|
209
|
+
|
|
210
|
+
const moveLineRangeUp = ({ start, end }, number) => {
|
|
211
|
+
return {
|
|
212
|
+
start: start - number,
|
|
213
|
+
end,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const moveLineRangeDown = ({ start, end }, number) => {
|
|
218
|
+
return {
|
|
219
|
+
start,
|
|
220
|
+
end: end + number,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const lineRangeWithinLines = ({ start, end }, lines) => {
|
|
225
|
+
return {
|
|
226
|
+
start: start < 0 ? 0 : start,
|
|
227
|
+
end: end > lines.length ? lines.length : end,
|
|
228
|
+
}
|
|
229
|
+
}
|
package/src/url_utils.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { urlToOrigin, urlToExtension, urlToRessource } from "@jsenv/filesystem"
|
|
2
|
+
|
|
3
|
+
export const asUrlWithoutSearch = (url) => {
|
|
4
|
+
const urlObject = new URL(url)
|
|
5
|
+
urlObject.search = ""
|
|
6
|
+
return urlObject.href
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const isValidUrl = (url) => {
|
|
10
|
+
try {
|
|
11
|
+
// eslint-disable-next-line no-new
|
|
12
|
+
new URL(url)
|
|
13
|
+
return true
|
|
14
|
+
} catch (e) {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// normalize url search params:
|
|
20
|
+
// Using URLSearchParams to alter the url search params
|
|
21
|
+
// can result into "file:///file.css?css_module"
|
|
22
|
+
// becoming "file:///file.css?css_module="
|
|
23
|
+
// we want to get rid of the "=" and consider it's the same url
|
|
24
|
+
export const normalizeUrl = (url) => {
|
|
25
|
+
if (url.includes("?")) {
|
|
26
|
+
// disable on data urls (would mess up base64 encoding)
|
|
27
|
+
if (url.startsWith("data:")) {
|
|
28
|
+
return url
|
|
29
|
+
}
|
|
30
|
+
return url.replace(/[=](?=&|$)/g, "")
|
|
31
|
+
}
|
|
32
|
+
return url
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const injectQueryParamsIntoSpecifier = (specifier, params) => {
|
|
36
|
+
if (isValidUrl(specifier)) {
|
|
37
|
+
return injectQueryParams(specifier, params)
|
|
38
|
+
}
|
|
39
|
+
const [beforeQuestion, afterQuestion = ""] = specifier.split("?")
|
|
40
|
+
const searchParams = new URLSearchParams(afterQuestion)
|
|
41
|
+
Object.keys(params).forEach((key) => {
|
|
42
|
+
searchParams.set(key, params[key])
|
|
43
|
+
})
|
|
44
|
+
const paramsString = searchParams.toString()
|
|
45
|
+
if (paramsString) {
|
|
46
|
+
return `${beforeQuestion}?${paramsString}`
|
|
47
|
+
}
|
|
48
|
+
return specifier
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const injectQueryParams = (url, params) => {
|
|
52
|
+
const urlObject = new URL(url)
|
|
53
|
+
Object.keys(params).forEach((key) => {
|
|
54
|
+
urlObject.searchParams.set(key, params[key])
|
|
55
|
+
})
|
|
56
|
+
const urlWithParams = urlObject.href
|
|
57
|
+
return urlWithParams
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const setUrlExtension = (url, extension) => {
|
|
61
|
+
const origin = urlToOrigin(url)
|
|
62
|
+
const currentExtension = urlToExtension(url)
|
|
63
|
+
const ressource = urlToRessource(url)
|
|
64
|
+
const [pathname, search] = ressource.split("?")
|
|
65
|
+
const pathnameWithoutExtension = currentExtension
|
|
66
|
+
? pathname.slice(0, -currentExtension.length)
|
|
67
|
+
: pathname
|
|
68
|
+
const newPathname = `${pathnameWithoutExtension}${extension}`
|
|
69
|
+
return `${origin}${newPathname}${search ? `?${search}` : ""}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const setUrlFilename = (url, filename) => {
|
|
73
|
+
const urlObject = new URL(url)
|
|
74
|
+
let { origin, search, hash } = urlObject
|
|
75
|
+
// origin is "null" for "file://" urls with Node.js
|
|
76
|
+
if (origin === "null" && urlObject.href.startsWith("file:")) {
|
|
77
|
+
origin = "file://"
|
|
78
|
+
}
|
|
79
|
+
const parentPathname = new URL("./", urlObject).pathname
|
|
80
|
+
return `${origin}${parentPathname}${filename}${search}${hash}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const ensurePathnameTrailingSlash = (url) => {
|
|
84
|
+
const urlObject = new URL(url)
|
|
85
|
+
const { pathname } = urlObject
|
|
86
|
+
if (pathname.endsWith("/")) {
|
|
87
|
+
return url
|
|
88
|
+
}
|
|
89
|
+
let { origin } = urlObject
|
|
90
|
+
// origin is "null" for "file://" urls with Node.js
|
|
91
|
+
if (origin === "null" && urlObject.href.startsWith("file:")) {
|
|
92
|
+
origin = "file://"
|
|
93
|
+
}
|
|
94
|
+
const { search, hash } = urlObject
|
|
95
|
+
return `${origin}${pathname}/${search}${hash}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const asUrlUntilPathname = (url) => {
|
|
99
|
+
const urlObject = new URL(url)
|
|
100
|
+
let { origin, pathname } = urlObject
|
|
101
|
+
// origin is "null" for "file://" urls with Node.js
|
|
102
|
+
if (origin === "null" && urlObject.href.startsWith("file:")) {
|
|
103
|
+
origin = "file://"
|
|
104
|
+
}
|
|
105
|
+
const urlUntilPathname = `${origin}${pathname}`
|
|
106
|
+
return urlUntilPathname
|
|
107
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const startsWithWindowsDriveLetter = (string) => {
|
|
2
|
+
const firstChar = string[0]
|
|
3
|
+
if (!/[a-zA-Z]/.test(firstChar)) return false
|
|
4
|
+
|
|
5
|
+
const secondChar = string[1]
|
|
6
|
+
if (secondChar !== ":") return false
|
|
7
|
+
|
|
8
|
+
return true
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const windowsFilePathToUrl = (windowsFilePath) => {
|
|
12
|
+
return `file:///${replaceBackSlashesWithSlashes(windowsFilePath)}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const replaceBackSlashesWithSlashes = (string) =>
|
|
16
|
+
string.replace(/\\/g, "/")
|