Skip to content

feat: Prettify URLs #372

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Oct 24, 2023
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
2 changes: 2 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: CI/CD

on:
push:
pull_request:
types: [opened, reopened]

env:
FORCE_COLOR: 3 # Diplay chalk colors
Expand Down
8 changes: 8 additions & 0 deletions packages/next-usequerystate/src/parsers.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, expect, test } from 'vitest'
import {
parseAsArrayOf,
parseAsFloat,
parseAsHex,
parseAsInteger,
parseAsIsoDateTime,
parseAsString,
parseAsTimestamp
} from './parsers'

Expand Down Expand Up @@ -46,4 +48,10 @@ describe('parsers', () => {
ref
)
})
test('parseAsArrayOf', () => {
const parser = parseAsArrayOf(parseAsString)
expect(parser.serialize([])).toBe('')
// It encodes its separator
expect(parser.serialize(['a', ',', 'b'])).toBe('a,%2C,b')
})
})
15 changes: 8 additions & 7 deletions packages/next-usequerystate/src/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ export function parseAsArrayOf<ItemType>(
itemParser: Parser<ItemType>,
separator = ','
) {
const encodedSeparator = encodeURIComponent(separator)
// todo: Handle default item values and make return type non-nullable
return createParser({
parse: query => {
Expand All @@ -274,19 +275,19 @@ export function parseAsArrayOf<ItemType>(
}
return query
.split(separator)
.map(item => decodeURIComponent(item))
.map(itemParser.parse)
.map(item =>
itemParser.parse(item.replaceAll(encodedSeparator, separator))
)
.filter(value => value !== null && value !== undefined) as ItemType[]
},
serialize: values =>
values
.map<string>(value => {
if (itemParser.serialize) {
return itemParser.serialize(value)
}
return `${value}`
const str = itemParser.serialize
? itemParser.serialize(value)
: String(value)
return str.replaceAll(separator, encodedSeparator)
})
.map(encodeURIComponent)
.join(separator)
})
}
3 changes: 2 additions & 1 deletion packages/next-usequerystate/src/update-queue.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Options, Router } from './defs'
import { NOSYNC_MARKER } from './sync'
import { renderQueryString } from './url-encoding'

// 50ms between calls to the history API seems to satisfy Chrome and Firefox.
// Safari remains annoying with at most 100 calls in 30 seconds. #wontfix
Expand Down Expand Up @@ -110,7 +111,7 @@ function flushUpdateQueue(router: Router) {
}
}

const query = search.toString()
const query = renderQueryString(search)
const path = window.location.pathname
const hash = window.location.hash

Expand Down
131 changes: 131 additions & 0 deletions packages/next-usequerystate/src/url-encoding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, expect, test } from 'vitest'
import { encodeQueryValue, renderQueryString } from './url-encoding'

describe('url-encoding/encodeQueryValue', () => {
test('spaces are encoded as +', () => {
expect(encodeQueryValue(' ')).toBe('+')
})
test('+ are encoded', () => {
expect(encodeQueryValue('+')).toBe(encodeURIComponent('+'))
})
test('Hashes are encoded', () => {
expect(encodeQueryValue('#')).toBe(encodeURIComponent('#'))
})
test('Ampersands are encoded', () => {
expect(encodeQueryValue('&')).toBe(encodeURIComponent('&'))
})
test('Percent signs are encoded', () => {
expect(encodeQueryValue('%')).toBe(encodeURIComponent('%'))
})
test('Characters that break URLs are encoded', () => {
expect(encodeQueryValue('"')).toEqual(encodeURIComponent('"'))
expect(encodeQueryValue("'")).toEqual('%27') // encodeURIComponent does not encode single quotes
expect(encodeQueryValue('`')).toEqual(encodeURIComponent('`'))
expect(encodeQueryValue('<')).toEqual(encodeURIComponent('<'))
expect(encodeQueryValue('>')).toEqual(encodeURIComponent('>'))
})
test('Alphanumericals are passed through', () => {
const input = 'abcdefghijklmnopqrstuvwxyz0123456789'
expect(encodeQueryValue(input)).toBe(input)
})
test('Other special characters are passed through', () => {
const input = '-._~!$()*,;=:@/?[]{}\\|^'
expect(encodeQueryValue(input)).toBe(input)
})
test('practical use-cases', () => {
const e = encodeQueryValue
expect(e('a b')).toBe('a+b')
expect(e('some#secret')).toBe('some%23secret')
expect(e('2+2=5')).toBe('2%2B2=5')
expect(e('100%')).toBe('100%25')
expect(e('kool&thegang')).toBe('kool%26thegang')
expect(e('a&b=c')).toBe('a%26b=c')
})
})

describe('url-encoding/renderQueryString', () => {
test('empty query', () => {
expect(renderQueryString(new URLSearchParams())).toBe('')
})
test('simple key-value pair', () => {
const search = new URLSearchParams()
search.set('foo', 'bar')
expect(renderQueryString(search)).toBe('foo=bar')
})
test('encoding', () => {
const search = new URLSearchParams()
search.set('test', '-._~!$()*,;=:@/?[]{}\\|^')
expect(renderQueryString(search)).toBe('test=-._~!$()*,;=:@/?[]{}\\|^')
})
test('decoding', () => {
const search = new URLSearchParams()
const value = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'
search.set('test', value)
const url = new URL('http://example.com/?' + renderQueryString(search))
expect(url.searchParams.get('test')).toBe(value)
})
test('decoding plus and spaces', () => {
const search = new URLSearchParams()
const value = 'a b+c'
search.set('test', value)
const url = new URL('http://example.com/?' + renderQueryString(search))
expect(url.searchParams.get('test')).toBe(value)
})
test('decoding hashes and fragment', () => {
const search = new URLSearchParams()
const value = 'foo#bar'
search.set('test', value)
const url = new URL(
'http://example.com/?' + renderQueryString(search) + '#egg'
)
expect(url.searchParams.get('test')).toBe(value)
})
test('decoding ampersands', () => {
const search = new URLSearchParams()
const value = 'a&b=c'
search.set('test', value)
const url = new URL(
'http://example.com/?' + renderQueryString(search) + '&egg=spam'
)
expect(url.searchParams.get('test')).toBe(value)
})
test('it renders query string with special characters', () => {
const search = new URLSearchParams()
search.set('name', 'John Doe')
search.set('email', '[email protected]')
search.set('message', 'Hello, world! #greeting')
const query = renderQueryString(search)
expect(query).toBe(
'name=John+Doe&email=foo.bar%[email protected]&message=Hello,+world!+%23greeting'
)
})
test('practical use-cases', () => {
// https://github.com/47ng/next-usequerystate/issues/355
{
const value =
'leftOfBicycleLane:car_lanes,curb|pavementHasShops:true|pavementWidth:narrow'
const search = new URLSearchParams()
search.set('filter', value)
const query = renderQueryString(search)
expect(query.slice('filter='.length)).toBe(value)
}
{
const url = new URL(
'https://radverkehrsatlas.de/regionen/trto?lat=53.6774&lng=13.267&zoom=10.6&theme=fromTo&bg=default&config=!(i~fromTo~topics~!(i~shops~s~!(i~hidden~a~_F)(i~default~a))(i~education~s~!(i~hidden~a)(i~default~a~_F))(i~places~s~!(i~hidden~a~_F)(i~default~a)(i~circle~a~_F))(i~buildings~s~!(i~hidden~a)(i~default~a~_F))(i~landuse~s~!(i~hidden~a~_F)(i~default~a))(i~barriers~s~!(i~hidden~a~_F)(i~default~a))(i~boundaries~s~!(i~hidden~a)(i~default~a~_F)(i~level-8~a~_F)(i~level-9-10~a~_F)))(i~bikelanes~topics~!(i~bikelanes~s~!(i~hidden~a~_F)(i~default~a)(i~verification~a~_F)(i~completeness~a~_F))(i~bikelanesPresence*_legacy~s~!(i~hidden~a)(i~default~a~_F))(i~places~s~!(i~hidden~a~_F)(i~default~a)(i~circle~a~_F))(i~landuse~s~!(i~hidden~a)(i~default~a~_F)))(i~roadClassification~topics~!(i~roadClassification*_legacy~s~!(i~hidden~a~_F)(i~default~a)(i~oneway~a~_F))(i~bikelanes~s~!(i~hidden~a)(i~default~a~_F)(i~verification~a~_F)(i~completeness~a~_F))(i~maxspeed*_legacy~s~!(i~hidden~a)(i~default~a~_F)(i~details~a~_F))(i~surfaceQuality*_legacy~s~!(i~hidden~a)(i~default~a~_F)(i~bad~a~_F)(i~completeness~a~_F)(i~freshness~a~_F))(i~places~s~!(i~hidden~a~_F)(i~default~a)(i~circle~a~_F))(i~landuse~s~!(i~hidden~a)(i~default~a~_F)))(i~lit~topics~!(i~lit*_legacy~s~!(i~hidden~a~_F)(i~default~a)(i~completeness~a~_F)(i~verification~a~_F)(i~freshness~a~_F))(i~places~s~!(i~hidden~a)(i~default~a~_F)(i~circle~a~_F))(i~landuse~s~!(i~hidden~a)(i~default~a~_F)))~'
)
const search = renderQueryString(url.searchParams)
expect(search).toBe(url.search.slice(1)) // drop the leading ?
}
})
})

test.skip('encodeURI vs encodeURIComponent vs custom encoding', () => {
const chars = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'.split('')
const table = chars.map(char => ({
char,
encodeQueryValue: encodeQueryValue(char),
encodeURI: encodeURI(char),
encodeURIComponent: encodeURIComponent(char)
}))
console.table(table)
})
31 changes: 31 additions & 0 deletions packages/next-usequerystate/src/url-encoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export function renderQueryString(search: URLSearchParams) {
const query: string[] = []
for (const [key, value] of search.entries()) {
query.push(`${key}=${encodeQueryValue(value)}`)
}
return query.join('&')
}

export function encodeQueryValue(input: string) {
return (
input
// Encode existing % signs first to avoid appearing
// as an incomplete escape sequence:
.replace(/%/g, '%25')
// Note: spaces are encoded as + in RFC 3986,
// so we pre-encode existing + signs to avoid confusion
// before converting spaces to + signs.
.replace(/\+/g, '%2B')
.replace(/ /g, '+')
// Encode other URI-reserved characters
.replace(/#/g, '%23')
.replace(/&/g, '%26')
// Encode characters that break URL detection on some platforms
// and would drop the tail end of the querystring:
.replace(/"/g, '%22')
.replace(/'/g, '%27')
.replace(/`/g, '%60')
.replace(/</g, '%3C')
.replace(/>/g, '%3E')
)
}
4 changes: 2 additions & 2 deletions packages/playground/src/app/demos/compound-parsers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { parseAsArrayOf, parseAsJson, useQueryState } from 'next-usequerystate'

const escaped = '-_.!~*\'()?#/&,"`<>{}[]@$£%+=:;'
const escaped = '-_.!~*\'()?#/&,"`<>{}[]|•@$£%+=:;'

export default function CompoundParsersDemo() {
const [code, setCode] = useQueryState(
Expand All @@ -11,7 +11,7 @@ export default function CompoundParsersDemo() {
)
const [array, setArray] = useQueryState(
'array',
parseAsArrayOf(parseAsJson<any>()).withDefault([])
parseAsArrayOf(parseAsJson<any>(), ';').withDefault([])
)
return (
<>
Expand Down
110 changes: 110 additions & 0 deletions packages/playground/src/app/demos/custom-parser/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
'use client'

import { createParser, useQueryState } from 'next-usequerystate'

type SortingState = Record<string, 'asc' | 'desc'>

const parser = createParser({
parse(value) {
if (value === '') {
return null
}
const keys = value.split('|')
return keys.reduce<SortingState>((acc, key) => {
const [id, desc] = key.split(':')
acc[id] = desc === 'desc' ? 'desc' : 'asc'
return acc
}, {})
},
serialize(value: SortingState) {
return Object.entries(value)
.map(([id, dir]) => `${id}:${dir}`)
.join('|')
}
})

export default function BasicCounterDemoPage() {
const [sort, setSort] = useQueryState('sort', parser.withDefault({}))
return (
<section>
<h1>Custom parser</h1>
<nav style={{ display: 'flex', gap: '4px' }}>
<span>Foo</span>
<button
style={{ padding: '2px 12px' }}
onClick={() =>
setSort(state => ({
...state,
foo: 'asc'
}))
}
>
🔼
</button>
<button
style={{ padding: '2px 12px' }}
onClick={() =>
setSort(state => ({
...state,
foo: 'desc'
}))
}
>
🔽
</button>
<button
style={{ padding: '2px 12px' }}
onClick={() =>
setSort(({ foo: _, ...state }) =>
Object.keys(state).length === 0 ? null : state
)
}
>
Clear
</button>
<span>{sort.foo}</span>
</nav>
<nav style={{ display: 'flex', gap: '4px' }}>
<span>Bar</span>
<button
style={{ padding: '2px 12px' }}
onClick={() =>
setSort(state => ({
...state,
bar: 'asc'
}))
}
>
🔼
</button>
<button
style={{ padding: '2px 12px' }}
onClick={() =>
setSort(state => ({
...state,
bar: 'desc'
}))
}
>
🔽
</button>
<button
style={{ padding: '2px 12px' }}
onClick={() =>
setSort(({ bar: _, ...state }) =>
Object.keys(state).length === 0 ? null : state
)
}
>
Clear
</button>
<span>{sort.bar}</span>
</nav>
<p>
<a href="https://github.com/47ng/next-usequerystate/blob/next/src/app/demos/custom-parser/page.tsx">
Source on GitHub
</a>
</p>
</section>
)
}
1 change: 1 addition & 0 deletions packages/playground/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const demos = [
'app/server-side-parsing',
'app/hex-colors',
'app/compound-parsers',
'app/custom-parser',
'app/crosslink',
'app/repro-359',
// Pages router demos
Expand Down