@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.
Files changed (55) hide show
  1. package/docs/url_meta/advanced.md +28 -0
  2. package/docs/url_meta/demo/apply_associations.mjs +27 -0
  3. package/docs/url_meta/demo/full_match.mjs +7 -0
  4. package/docs/url_meta/demo/partial_match.mjs +7 -0
  5. package/docs/url_meta/demo/resolve_associations.mjs +12 -0
  6. package/docs/url_meta/notes.md +26 -0
  7. package/docs/url_meta/pattern_matching.md +71 -0
  8. package/docs/url_meta/readme.md +87 -0
  9. package/main.js +27 -0
  10. package/package.json +28 -0
  11. package/src/caller_position.js +20 -0
  12. package/src/data_url.js +50 -0
  13. package/src/inline_content_url_generator.js +21 -0
  14. package/src/meta/aliases.js +28 -0
  15. package/src/meta/as_flat_associations.js +28 -0
  16. package/src/meta/assertions.js +57 -0
  17. package/src/meta/associations.js +25 -0
  18. package/src/meta/pattern_matching.js +245 -0
  19. package/src/meta/resolve_associations.js +25 -0
  20. package/src/meta/url_child_may_match.js +47 -0
  21. package/src/meta/url_meta.js +13 -0
  22. package/src/url_trace.js +229 -0
  23. package/src/url_utils.js +107 -0
  24. package/src/windows_file_path_utils.js +16 -0
  25. package/test/data_url.test.mjs +57 -0
  26. package/test/meta/aliasing.test.mjs +47 -0
  27. package/test/meta/as_flat_associations/basic.test.mjs +64 -0
  28. package/test/meta/assertions.test.mjs +23 -0
  29. package/test/meta/get_url_meta/any.test.mjs +94 -0
  30. package/test/meta/get_url_meta/basic.test.mjs +226 -0
  31. package/test/meta/get_url_meta/complex.test.mjs +203 -0
  32. package/test/meta/get_url_meta/double_wildcard_after.test.mjs +68 -0
  33. package/test/meta/get_url_meta/double_wildcard_around.test.mjs +42 -0
  34. package/test/meta/get_url_meta/double_wildcard_before.test.mjs +55 -0
  35. package/test/meta/get_url_meta/inheritance.test.mjs +59 -0
  36. package/test/meta/is_plain_object.test.mjs +27 -0
  37. package/test/meta/pattern_matching/basic.test.mjs +100 -0
  38. package/test/meta/pattern_matching/common.test.mjs +145 -0
  39. package/test/meta/pattern_matching/complex.test.mjs +159 -0
  40. package/test/meta/pattern_matching/contains.test.mjs +46 -0
  41. package/test/meta/pattern_matching/doublewildcard_after.test.mjs +81 -0
  42. package/test/meta/pattern_matching/doublewildcard_around.test.mjs +42 -0
  43. package/test/meta/pattern_matching/doublewildcard_before.test.mjs +68 -0
  44. package/test/meta/pattern_matching/doublewildcard_inside.test.mjs +42 -0
  45. package/test/meta/pattern_matching/trailing_slash.test.mjs +55 -0
  46. package/test/meta/pattern_matching/wildcard_after.test.mjs +68 -0
  47. package/test/meta/pattern_matching/wildcard_around.test.mjs +94 -0
  48. package/test/meta/pattern_matching/wildcard_before.test.mjs +55 -0
  49. package/test/meta/pattern_matching/wildcard_inside.test.mjs +68 -0
  50. package/test/meta/resolve_associations/basic.test.mjs +42 -0
  51. package/test/meta/resolve_associations/http_pattern.test.mjs +35 -0
  52. package/test/meta/url_child_may_match/inheritance.test.mjs +71 -0
  53. package/test/meta/url_child_may_match/url_child_may_match.test.mjs +166 -0
  54. package/test/url_trace.test.mjs +224 -0
  55. 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
+ }
@@ -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
+ }
@@ -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, "/")