Skip to content

Commit df29c44

Browse files
committed
feat: x+hh for special characters
Close #576
1 parent d7051c1 commit df29c44

File tree

3 files changed

+204
-2
lines changed

3 files changed

+204
-2
lines changed

src/core/extendRoutes.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,4 +300,32 @@ describe('EditableTreeNode', () => {
300300
expect(parent.fullPath).toBe('/bar')
301301
expect(child.fullPath).toBe('/bar/child')
302302
})
303+
304+
describe('special characters (same as raw [x+hh])', () => {
305+
it('supports hex encoding in path format with single segment', () => {
306+
const tree = new PrefixTree(RESOLVED_OPTIONS)
307+
const editable = new EditableTreeNode(tree)
308+
309+
const child = editable.insert('.well-known', 'file.vue')
310+
expect(child).toBeDefined()
311+
312+
expect(child.fullPath).toBe('/.well-known')
313+
expect(child.path).toBe('/.well-known')
314+
})
315+
316+
it('special character within a param', () => {
317+
const tree = new PrefixTree(RESOLVED_OPTIONS)
318+
const editable = new EditableTreeNode(tree)
319+
320+
const child = editable.insert(':id.test', 'file.vue')
321+
322+
expect(child.fullPath).toBe('/:id.test')
323+
expect(child.params).toMatchObject([
324+
{
325+
paramName: 'id',
326+
modifier: '',
327+
},
328+
])
329+
})
330+
})
303331
})

src/core/tree.spec.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,146 @@ describe('Tree', () => {
140140
})
141141
})
142142

143+
describe('special character encoding [x+hh]', () => {
144+
it('parses single hex character code', () => {
145+
const tree = new PrefixTree(RESOLVED_OPTIONS)
146+
const child = tree.insert('[x+2E]well-known', '[x+2E]well-known.vue')
147+
expect(child).toBeDefined()
148+
149+
expect(child.value).toMatchObject({
150+
rawSegment: '[x+2E]well-known',
151+
pathSegment: '.well-known',
152+
})
153+
expect(child.fullPath).toBe('/.well-known')
154+
expect(child.value.params).toEqual([])
155+
expect(child.value.isStatic()).toBe(true)
156+
})
157+
158+
it('parses multiple hex character codes in separate brackets', () => {
159+
const tree = new PrefixTree(RESOLVED_OPTIONS)
160+
const child = tree.insert('[x+2E][x+2F]test', '[x+2E][x+2F]test.vue')
161+
162+
expect(child.value).toMatchObject({
163+
rawSegment: '[x+2E][x+2F]test',
164+
pathSegment: './test',
165+
})
166+
expect(child.fullPath).toBe('/./test')
167+
expect(child.value.isStatic()).toBe(true)
168+
})
169+
170+
it('parses hex codes mixed with static prefix', () => {
171+
const tree = new PrefixTree(RESOLVED_OPTIONS)
172+
const child = tree.insert('prefix-[x+2E]-suffix', 'file.vue')
173+
174+
expect(child.value).toMatchObject({
175+
rawSegment: 'prefix-[x+2E]-suffix',
176+
pathSegment: 'prefix-.-suffix',
177+
})
178+
expect(child.fullPath).toBe('/prefix-.-suffix')
179+
expect(child.value.isStatic()).toBe(true)
180+
})
181+
182+
it('creates smiley route path', () => {
183+
const tree = new PrefixTree(RESOLVED_OPTIONS)
184+
const smileyNode = tree.insert(
185+
'smileys/[x+3A]-[x+29]',
186+
'smileys/[x+3A]-[x+29].vue'
187+
)
188+
189+
expect(smileyNode.value).toMatchObject({
190+
pathSegment: ':-)',
191+
})
192+
expect(smileyNode.fullPath).toBe('/smileys/:-)')
193+
expect(smileyNode.value.isStatic()).toBe(true)
194+
})
195+
196+
it('allows lowercase hex codes', () => {
197+
const tree = new PrefixTree(RESOLVED_OPTIONS)
198+
tree.insert('[x+2e]test', '[x+2e]test.vue')
199+
const child = tree.children.get('[x+2e]test')!
200+
201+
expect(child.value.pathSegment).toBe('.test')
202+
})
203+
204+
it('allows mixed case hex codes', () => {
205+
const tree = new PrefixTree(RESOLVED_OPTIONS)
206+
const child = tree.insert('[x+2F][x+2e]', 'file.vue')
207+
expect(child.value.pathSegment).toBe('/.')
208+
})
209+
210+
it('throws on invalid hex code (non-hex characters)', () => {
211+
const tree = new PrefixTree(RESOLVED_OPTIONS)
212+
213+
expect(() => tree.insert('[x+ZZ]', '[x+ZZ].vue')).toThrow(
214+
/Invalid hex code "ZZ"/
215+
)
216+
})
217+
218+
it('throws on incomplete hex code (single digit)', () => {
219+
const tree = new PrefixTree(RESOLVED_OPTIONS)
220+
221+
expect(() => tree.insert('[x+2]', '[x+2].vue')).toThrow(
222+
/must be exactly 2 digits/
223+
)
224+
})
225+
226+
it('throws on too many digits in hex code', () => {
227+
const tree = new PrefixTree(RESOLVED_OPTIONS)
228+
229+
expect(() => tree.insert('[x+2EE]', '[x+2EE].vue')).toThrow(
230+
/code must be exactly 2 digits/
231+
)
232+
})
233+
234+
it('throws on empty hex code', () => {
235+
const tree = new PrefixTree(RESOLVED_OPTIONS)
236+
237+
expect(() => tree.insert('[x+]', '[x+].vue')).toThrow(
238+
/must be exactly 2 digits/
239+
)
240+
})
241+
242+
it('throws on unclosed hex code bracket', () => {
243+
const tree = new PrefixTree(RESOLVED_OPTIONS)
244+
245+
expect(() => tree.insert('[x+2E', '[x+2E.vue')).toThrow(/Invalid segment/)
246+
})
247+
248+
it('does not interfere with regular params', () => {
249+
const tree = new PrefixTree(RESOLVED_OPTIONS)
250+
tree.insert('[id]-[x+2E]-[name]', '[id]-[x+2E]-[name].vue')
251+
const child = tree.children.get('[id]-[x+2E]-[name]')!
252+
253+
expect(child.value.isParam()).toBe(true)
254+
expect(child.value.params).toHaveLength(2)
255+
expect(child.value.params[0]).toMatchObject({ paramName: 'id' })
256+
expect(child.value.params[1]).toMatchObject({ paramName: 'name' })
257+
expect(child.value.pathSegment).toContain('-.-')
258+
expect(child.value.pathSegment).toContain(':id')
259+
expect(child.value.pathSegment).toContain(':name')
260+
})
261+
262+
it('does not treat param starting with x as hex code', () => {
263+
const tree = new PrefixTree(RESOLVED_OPTIONS)
264+
tree.insert('[xid]', '[xid].vue')
265+
const child = tree.children.get('[xid]')!
266+
267+
expect(child.value.isParam()).toBe(true)
268+
expect(child.value.params[0]).toMatchObject({ paramName: 'xid' })
269+
expect(child.value.pathSegment).toBe(':xid')
270+
})
271+
272+
it('treats param named exactly x as normal param', () => {
273+
const tree = new PrefixTree(RESOLVED_OPTIONS)
274+
tree.insert('[x]', '[x].vue')
275+
const child = tree.children.get('[x]')!
276+
277+
expect(child.value.isParam()).toBe(true)
278+
expect(child.value.params[0]).toMatchObject({ paramName: 'x' })
279+
expect(child.value.pathSegment).toBe(':x')
280+
})
281+
})
282+
143283
it('separate param names from static segments', () => {
144284
const tree = new PrefixTree(RESOLVED_OPTIONS)
145285
tree.insert('[id]_a', '[id]_a.vue')

src/core/treeNodeValue.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,7 @@ const enum ParseFileSegmentState {
558558
param, // within []
559559
paramParser, // [param=type]
560560
modifier, // after the ]
561+
charCode, // [x+HH] hex character code
561562
}
562563

563564
/**
@@ -633,6 +634,19 @@ function parseFileSegment(
633634
params.push(currentTreeRouteParam)
634635
subSegments.push(currentTreeRouteParam)
635636
currentTreeRouteParam = createEmptyRouteParam()
637+
} else if (state === ParseFileSegmentState.charCode) {
638+
if (buffer.length !== 2) {
639+
throw new SyntaxError(
640+
`Invalid character code in segment "${segment}". Hex code must be exactly 2 digits, got "${buffer}"`
641+
)
642+
}
643+
const hexCode = parseInt(buffer, 16)
644+
if (!Number.isInteger(hexCode) || hexCode < 0 || hexCode > 255) {
645+
throw new SyntaxError(
646+
`Invalid hex code "${buffer}" in segment "${segment}"`
647+
)
648+
}
649+
pathSegment += String.fromCharCode(hexCode)
636650
}
637651
buffer = ''
638652
}
@@ -675,8 +689,19 @@ function parseFileSegment(
675689
currentTreeRouteParam.isSplat = true
676690
pos += 2 // skip the other 2 dots
677691
} else if (c === '=') {
692+
// TODO: better error if param name is empty
678693
state = ParseFileSegmentState.paramParser
679694
paramParserBuffer = ''
695+
} else if (
696+
c === '+' &&
697+
buffer === 'x' &&
698+
!currentTreeRouteParam.isSplat &&
699+
!currentTreeRouteParam.optional
700+
) {
701+
// Found [x+ pattern - switch to hex character code parsing
702+
// This is NOT a parameter, it's a special character encoding
703+
buffer = ''
704+
state = ParseFileSegmentState.charCode
680705
} else {
681706
buffer += c
682707
}
@@ -700,15 +725,24 @@ function parseFileSegment(
700725
} else {
701726
paramParserBuffer += c
702727
}
728+
} else if (state === ParseFileSegmentState.charCode) {
729+
// Parsing hex character code: [x+HH] where HH is 2 hex digits
730+
if (c === ']') {
731+
consumeBuffer()
732+
state = ParseFileSegmentState.static
733+
} else {
734+
buffer += c
735+
}
703736
}
704737
}
705738

706739
if (
707740
state === ParseFileSegmentState.param ||
708741
state === ParseFileSegmentState.paramOptional ||
709-
state === ParseFileSegmentState.paramParser
742+
state === ParseFileSegmentState.paramParser ||
743+
state === ParseFileSegmentState.charCode
710744
) {
711-
throw new Error(`Invalid segment: "${segment}"`)
745+
throw new SyntaxError(`Invalid segment: "${segment}"`)
712746
}
713747

714748
if (buffer) {

0 commit comments

Comments
 (0)