sanity
Version:
Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches
207 lines (176 loc) • 6.11 kB
text/typescript
import {omit} from 'lodash'
import {decodeJsonParams, encodeJsonParams, route} from 'sanity/router'
import {type RouterPaneGroup, type RouterPanes, type RouterPaneSibling} from './types'
const EMPTY_PARAMS = {}
/**
* @internal
*/
export function legacyEditParamsToState(params: string): Record<string, unknown> {
try {
return JSON.parse(decodeURIComponent(params))
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Failed to parse JSON parameters')
return {}
}
}
export function encodePanesSegment(panes: RouterPanes): string {
return (panes || [])
.map((group) => group.map(encodeChunks).join('|'))
.map(encodeURIComponent)
.join(';')
}
/**
* @internal
*/
export function legacyEditParamsToPath(params: Record<string, unknown>): string {
return JSON.stringify(params)
}
// http://localhost:3333/intent/create/template=book-by-author;type=book/eyJhdXRob3JJZCI6Imdycm0ifQ==
/**
* @internal
*/
export function toState(pathSegment: string): RouterPaneGroup[] {
return parsePanesSegment(decodeURIComponent(pathSegment))
}
/**
* @internal
*/
export function toPath(panes: RouterPaneGroup[]): string {
return encodePanesSegment(panes)
}
export const router = route.create('/', [
// "Asynchronous intent resolving" route
route.intents('/intent'),
// Legacy fallback route, will be redirected to new format
route.create('/edit/:type/:editDocumentId', [
route.create({
path: '/:params',
transform: {params: {toState: legacyEditParamsToState, toPath: legacyEditParamsToPath}},
}),
]),
// The regular path - when the intent can be resolved to a specific pane
route.create({
path: '/:panes',
// Legacy URLs, used to handle redirects
children: [route.create('/:action', route.create('/:legacyEditDocumentId'))],
transform: {
panes: {toState, toPath},
},
}),
])
// old: authors;knut,{"template":"diaryEntry"}
// new: authors;knut,view=diff,eyJyZXYxIjoiYWJjMTIzIiwicmV2MiI6ImRlZjQ1NiJ9|latest-posts
const panePattern = /^([.a-z0-9_-]+),?({.*?})?(?:(;|$))/i
const isParam = (str: string) => /^[a-z0-9]+=[^=]+/i.test(str)
const isPayloadLike = (str: string) => /^[A-Za-z0-9\-_]+(?:={0,2})$/.test(str)
const exclusiveParams = ['view', 'since', 'rev', 'inspect', 'comment']
type Truthy<T> = T extends false
? never
: T extends ''
? never
: T extends 0
? never
: T extends 0n
? never
: T extends null | undefined
? NonNullable<T>
: T
const isTruthy = Boolean as (t: unknown) => boolean as <T>(t: T) => t is Truthy<T>
function parseChunks(chunks: string[], initial: RouterPaneSibling): RouterPaneSibling {
const sibling: RouterPaneSibling = {...initial, params: EMPTY_PARAMS, payload: undefined}
return chunks.reduce((pane, chunk) => {
if (isParam(chunk)) {
const key = chunk.slice(0, chunk.indexOf('='))
const value = chunk.slice(key.length + 1)
pane.params = {...pane.params, [decodeURIComponent(key)]: decodeURIComponent(value)}
} else if (isPayloadLike(chunk)) {
pane.payload = tryParseBase64Payload(chunk)
} else {
// eslint-disable-next-line no-console
console.warn('Unknown pane segment: %s - skipping', chunk)
}
return pane
}, sibling)
}
function encodeChunks(pane: RouterPaneSibling, index: number, group: RouterPaneGroup): string {
const {payload, params = {}, id} = pane
const [firstSibling] = group
const paneIsFirstSibling = pane === firstSibling
const sameAsFirst = index !== 0 && id === firstSibling.id
const encodedPayload =
typeof payload === 'undefined' ? undefined : encodeJsonParams(payload as any)
const encodedParams = Object.entries(params)
.filter((entry): entry is [string, string] => {
const [key, value] = entry
if (!value) return false
if (paneIsFirstSibling) return true
// omit the value if it's the same as the value from the first sibling
const valueFromFirstSibling = firstSibling.params?.[key]
if (value === valueFromFirstSibling && !exclusiveParams.includes(key)) return false
return true
})
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
return (
[sameAsFirst ? '' : id]
.concat([encodedParams.length > 0 && encodedParams, encodedPayload].filter(isTruthy).flat())
.join(',') || ','
)
}
export function parsePanesSegment(str: string): RouterPanes {
if (str.indexOf(',{') !== -1) {
return parseOldPanesSegment(str)
}
return str
.split(';')
.map((group) => {
const [firstSibling, ...restOfSiblings] = group.split('|').map((segment) => {
const [id, ...chunks] = segment.split(',')
return parseChunks(chunks, {id})
})
return [
firstSibling,
...restOfSiblings.map((sibling) => ({
...firstSibling,
...sibling,
id: sibling.id || firstSibling.id,
params: {...omit(firstSibling.params, exclusiveParams), ...sibling.params},
payload: sibling.payload || firstSibling.payload,
})),
]
})
.filter((group) => group.length > 0)
}
function parseOldPanesSegment(str: string): RouterPanes {
const chunks: RouterPaneGroup = []
let buffer = str
while (buffer.length) {
const [match, id, payloadChunk] = buffer.match(panePattern) || []
if (!match) {
buffer = buffer.slice(1)
continue
}
const payload = payloadChunk && tryParsePayload(payloadChunk)
chunks.push({id, payload})
buffer = buffer.slice(match.length)
}
return [chunks]
}
function tryParsePayload(json: string) {
try {
return JSON.parse(json)
} catch (err) {
// eslint-disable-next-line no-console
console.warn(`Failed to parse parameters: ${err.message}`)
return undefined
}
}
function tryParseBase64Payload(data: string): unknown {
try {
return data ? decodeJsonParams(data) : undefined
} catch (err) {
// eslint-disable-next-line no-console
console.warn(`Failed to parse parameters: ${err.message}`)
return undefined
}
}