Skip to content

Commit afaf086

Browse files
committed
fix: only encode when needed
1 parent 583509f commit afaf086

File tree

7 files changed

+305
-2
lines changed

7 files changed

+305
-2
lines changed

playground/typed-router.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ declare module 'vue-router/auto-routes' {
186186
>,
187187
'/emoji-🤡': RouteRecordInfo<
188188
'/emoji-🤡',
189-
'/emoji-🤡',
189+
'/emoji-%F0%9F%A4%A1',
190190
Record<never, never>,
191191
Record<never, never>,
192192
| never

src/codegen/__snapshots__/generateRouteRecords.spec.ts.snap

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,49 @@ exports[`generateRouteRecord > correctly names index files 1`] = `
6161
]"
6262
`;
6363

64+
exports[`generateRouteRecord > does not encode RFC 3986 valid path characters 1`] = `
65+
"[
66+
{
67+
path: '/@profile',
68+
name: '/@profile',
69+
component: () => import('@profile.vue'),
70+
/* no children */
71+
},
72+
{
73+
path: '/foo*bar',
74+
name: '/foo*bar',
75+
component: () => import('foo*bar.vue'),
76+
/* no children */
77+
},
78+
{
79+
path: '/hello!',
80+
name: '/hello!',
81+
component: () => import('hello!.vue'),
82+
/* no children */
83+
},
84+
{
85+
path: '/it's-fine',
86+
name: '/it's-fine',
87+
component: () => import('it's-fine.vue'),
88+
/* no children */
89+
},
90+
{
91+
path: '/item(1)',
92+
name: '/item(1)',
93+
component: () => import('item(1).vue'),
94+
/* no children */
95+
},
96+
{
97+
path: '/user',
98+
name: '/user',
99+
components: {
100+
'domain': () => import('user@domain.vue')
101+
},
102+
/* no children */
103+
}
104+
]"
105+
`;
106+
64107
exports[`generateRouteRecord > encodes special characters in path segments 1`] = `
65108
"[
66109
{

src/codegen/__snapshots__/generateRouteResolver.spec.ts.snap

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,66 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3+
exports[`generateRouteResolver > does not encode RFC 3986 valid path characters 1`] = `
4+
"
5+
const __route_0 = normalizeRouteRecord({
6+
name: '/@profile',
7+
path: new MatcherPatternPathStatic('/@profile'),
8+
components: {
9+
'default': () => import('@profile.vue')
10+
},
11+
})
12+
13+
const __route_1 = normalizeRouteRecord({
14+
name: '/foo*bar',
15+
path: new MatcherPatternPathStatic('/foo*bar'),
16+
components: {
17+
'default': () => import('foo*bar.vue')
18+
},
19+
})
20+
21+
const __route_2 = normalizeRouteRecord({
22+
name: '/hello!',
23+
path: new MatcherPatternPathStatic('/hello!'),
24+
components: {
25+
'default': () => import('hello!.vue')
26+
},
27+
})
28+
29+
const __route_3 = normalizeRouteRecord({
30+
name: '/it's-fine',
31+
path: new MatcherPatternPathStatic('/it's-fine'),
32+
components: {
33+
'default': () => import('it's-fine.vue')
34+
},
35+
})
36+
37+
const __route_4 = normalizeRouteRecord({
38+
name: '/item(1)',
39+
path: new MatcherPatternPathStatic('/item(1)'),
40+
components: {
41+
'default': () => import('item(1).vue')
42+
},
43+
})
44+
45+
const __route_5 = normalizeRouteRecord({
46+
name: '/user',
47+
path: new MatcherPatternPathStatic('/user'),
48+
components: {
49+
'domain': () => import('user@domain.vue')
50+
},
51+
})
52+
53+
export const resolver = createFixedResolver([
54+
__route_0, // /@profile
55+
__route_1, // /foo*bar
56+
__route_2, // /hello!
57+
__route_3, // /it's-fine
58+
__route_4, // /item(1)
59+
__route_5, // /user
60+
])
61+
"
62+
`;
63+
364
exports[`generateRouteResolver > encodes special characters in route resolver paths 1`] = `
465
"
566
const __route_0 = normalizeRouteRecord({

src/codegen/generateRouteRecords.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,22 @@ describe('generateRouteRecord', () => {
113113
expect(generateRouteRecordSimple(tree)).toMatchSnapshot()
114114
})
115115

116+
it('does not encode RFC 3986 valid path characters', () => {
117+
const tree = new PrefixTree(DEFAULT_OPTIONS)
118+
119+
// @ character (common in profile/user routes)
120+
tree.insert('@profile', '@profile.vue')
121+
tree.insert('user@domain', 'user@domain.vue')
122+
123+
// Other valid sub-delimiters
124+
tree.insert('hello!', 'hello!.vue')
125+
tree.insert("it's-fine", "it's-fine.vue")
126+
tree.insert('item(1)', 'item(1).vue')
127+
tree.insert('foo*bar', 'foo*bar.vue')
128+
129+
expect(generateRouteRecordSimple(tree)).toMatchSnapshot()
130+
})
131+
116132
it('generate static imports', () => {
117133
const options = resolveOptions({
118134
...DEFAULT_OPTIONS,

src/codegen/generateRouteResolver.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,4 +920,27 @@ describe('generateRouteResolver', () => {
920920

921921
expect(resolver).toMatchSnapshot()
922922
})
923+
924+
it('does not encode RFC 3986 valid path characters', () => {
925+
const tree = new PrefixTree(DEFAULT_OPTIONS)
926+
927+
// @ character (common in profile/user routes)
928+
tree.insert('@profile', '@profile.vue')
929+
tree.insert('user@domain', 'user@domain.vue')
930+
931+
// Other valid sub-delimiters
932+
tree.insert('hello!', 'hello!.vue')
933+
tree.insert("it's-fine", "it's-fine.vue")
934+
tree.insert('item(1)', 'item(1).vue')
935+
tree.insert('foo*bar', 'foo*bar.vue')
936+
937+
const resolver = generateRouteResolver(
938+
tree,
939+
DEFAULT_OPTIONS,
940+
new ImportsMap(),
941+
new Map()
942+
)
943+
944+
expect(resolver).toMatchSnapshot()
945+
})
923946
})

src/core/treeNodeValue.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
CustomRouteBlockQueryParamOptions,
55
} from './customBlock'
66
import { joinPath, mergeRouteRecordOverride, warn } from './utils'
7+
import { encodePath } from '../utils/encoding'
78

89
export const enum TreeNodeType {
910
static,
@@ -606,7 +607,7 @@ function parseFileSegment(
606607
// Encode static segments for URL safety, but preserve slashes from dotNesting
607608
const encodedBuffer = buffer
608609
.split('/')
609-
.map((part) => encodeURIComponent(part))
610+
.map((part) => encodePath(part))
610611
.join('/')
611612
pathSegment += encodedBuffer
612613
subSegments.push(encodedBuffer)

src/utils/encoding.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { warn } from '../core/utils'
2+
3+
/**
4+
* Encoding Rules (␣ = Space)
5+
* - Path: ␣ " < > # ? { }
6+
* - Query: ␣ " < > # & =
7+
* - Hash: ␣ " < > `
8+
*
9+
* On top of that, the RFC3986 (https://tools.ietf.org/html/rfc3986#section-2.2)
10+
* defines some extra characters to be encoded. Most browsers do not encode them
11+
* in encodeURI https://github.com/whatwg/url/issues/369, so it may be safer to
12+
* also encode `!'()*`. Leaving un-encoded only ASCII alphanumeric(`a-zA-Z0-9`)
13+
* plus `-._~`. This extra safety should be applied to query by patching the
14+
* string returned by encodeURIComponent encodeURI also encodes `[\]^`. `\`
15+
* should be encoded to avoid ambiguity. Browsers (IE, FF, C) transform a `\`
16+
* into a `/` if directly typed in. The _backtick_ (`````) should also be
17+
* encoded everywhere because some browsers like FF encode it when directly
18+
* written while others don't. Safari and IE don't encode ``"<>{}``` in hash.
19+
*/
20+
// const EXTRA_RESERVED_RE = /[!'()*]/g
21+
// const encodeReservedReplacer = (c: string) => '%' + c.charCodeAt(0).toString(16)
22+
23+
const HASH_RE = /#/g // %23
24+
const AMPERSAND_RE = /&/g // %26
25+
export const SLASH_RE = /\//g // %2F
26+
const EQUAL_RE = /=/g // %3D
27+
const IM_RE = /\?/g // %3F
28+
export const PLUS_RE = /\+/g // %2B
29+
/**
30+
* NOTE: It's not clear to me if we should encode the + symbol in queries, it
31+
* seems to be less flexible than not doing so and I can't find out the legacy
32+
* systems requiring this for regular requests like text/html. In the standard,
33+
* the encoding of the plus character is only mentioned for
34+
* application/x-www-form-urlencoded
35+
* (https://url.spec.whatwg.org/#urlencoded-parsing) and most browsers seems lo
36+
* leave the plus character as is in queries. To be more flexible, we allow the
37+
* plus character on the query, but it can also be manually encoded by the user.
38+
*
39+
* Resources:
40+
* - https://url.spec.whatwg.org/#urlencoded-parsing
41+
* - https://stackoverflow.com/questions/1634271/url-encoding-the-space-character-or-20
42+
*/
43+
44+
const ENC_BRACKET_OPEN_RE = /%5B/g // [
45+
const ENC_BRACKET_CLOSE_RE = /%5D/g // ]
46+
const ENC_CARET_RE = /%5E/g // ^
47+
const ENC_BACKTICK_RE = /%60/g // `
48+
const ENC_CURLY_OPEN_RE = /%7B/g // {
49+
const ENC_PIPE_RE = /%7C/g // |
50+
const ENC_CURLY_CLOSE_RE = /%7D/g // }
51+
const ENC_SPACE_RE = /%20/g // }
52+
53+
/**
54+
* Encode characters that need to be encoded on the path, search and hash
55+
* sections of the URL.
56+
*
57+
* @internal
58+
* @param text - string to encode
59+
* @returns encoded string
60+
*/
61+
export function commonEncode(text: string | number | null | undefined): string {
62+
return text == null
63+
? ''
64+
: encodeURI('' + text)
65+
.replace(ENC_PIPE_RE, '|')
66+
.replace(ENC_BRACKET_OPEN_RE, '[')
67+
.replace(ENC_BRACKET_CLOSE_RE, ']')
68+
}
69+
70+
/**
71+
* Encode characters that need to be encoded on the hash section of the URL.
72+
*
73+
* @param text - string to encode
74+
* @returns encoded string
75+
*/
76+
export function encodeHash(text: string): string {
77+
return commonEncode(text)
78+
.replace(ENC_CURLY_OPEN_RE, '{')
79+
.replace(ENC_CURLY_CLOSE_RE, '}')
80+
.replace(ENC_CARET_RE, '^')
81+
}
82+
83+
/**
84+
* Encode characters that need to be encoded query values on the query
85+
* section of the URL.
86+
*
87+
* @param text - string to encode
88+
* @returns encoded string
89+
*/
90+
export function encodeQueryValue(text: string | number): string {
91+
return (
92+
commonEncode(text)
93+
// Encode the space as +, encode the + to differentiate it from the space
94+
.replace(PLUS_RE, '%2B')
95+
.replace(ENC_SPACE_RE, '+')
96+
.replace(HASH_RE, '%23')
97+
.replace(AMPERSAND_RE, '%26')
98+
.replace(ENC_BACKTICK_RE, '`')
99+
.replace(ENC_CURLY_OPEN_RE, '{')
100+
.replace(ENC_CURLY_CLOSE_RE, '}')
101+
.replace(ENC_CARET_RE, '^')
102+
)
103+
}
104+
105+
/**
106+
* Like `encodeQueryValue` but also encodes the `=` character.
107+
*
108+
* @param text - string to encode
109+
*/
110+
export function encodeQueryKey(text: string | number): string {
111+
return encodeQueryValue(text).replace(EQUAL_RE, '%3D')
112+
}
113+
114+
/**
115+
* Encode characters that need to be encoded on the path section of the URL.
116+
*
117+
* @param text - string to encode
118+
* @returns encoded string
119+
*/
120+
export function encodePath(text: string | number | null | undefined): string {
121+
return commonEncode(text).replace(HASH_RE, '%23').replace(IM_RE, '%3F')
122+
}
123+
124+
/**
125+
* Encode characters that need to be encoded on the path section of the URL as a
126+
* param. This function encodes everything {@link encodePath} does plus the
127+
* slash (`/`) character. If `text` is `null` or `undefined`, returns an empty
128+
* string instead.
129+
*
130+
* @param text - string to encode
131+
* @returns encoded string
132+
*/
133+
export function encodeParam(text: string | number | null | undefined): string {
134+
return encodePath(text).replace(SLASH_RE, '%2F')
135+
}
136+
137+
/**
138+
* Decode text using `decodeURIComponent`. Returns the original text if it
139+
* fails.
140+
*
141+
* @param text - string to decode
142+
* @returns decoded string
143+
*/
144+
export function decode(text: string | number): string
145+
export function decode(text: null | undefined): null
146+
export function decode(text: string | number | null | undefined): string | null
147+
export function decode(
148+
text: string | number | null | undefined
149+
): string | null {
150+
if (text == null) return null
151+
try {
152+
return decodeURIComponent('' + text)
153+
} catch (err) {
154+
if (process.env.NODE_ENV !== 'production') {
155+
warn(`Error decoding "${text}". Using original value`)
156+
}
157+
}
158+
return '' + text
159+
}

0 commit comments

Comments
 (0)