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
198 lines (178 loc) • 5.39 kB
text/typescript
/**
* @internal
* @hidden
*/
export type OpenTagToken = {
type: 'tagOpen'
name: string
selfClosing?: boolean
}
/**
* @internal
* @hidden
*/
export type CloseTagToken = {
type: 'tagClose'
name: string
}
/**
* @internal
* @hidden
*/
export type TextToken = {
type: 'text'
text: string
}
/**
* @internal
* @hidden
*/
export type InterpolationToken = {
type: 'interpolation'
variable: string
}
/**
* @internal
* @hidden
*/
export type Token = OpenTagToken | CloseTagToken | TextToken | InterpolationToken
const OPEN_TAG_RE = /^<(?<tag>[^\s\d<][^/?><]+)\/?>/
const CLOSE_TAG_RE = /<\/(?<tag>[^>]+)>/
const SELF_CLOSING_RE = /<[^>]+\/>/
const VALID_COMPONENT_NAME_RE = /^[A-Z][A-Za-z0-9]+$/
const VALID_HTML_TAG_NAME_RE = /^[a-z]+$/
const TEMPLATE_RE = /{{\s*?([^}]+)\s*?}}/g
/**
* Parses a string for simple tags
*
* @param input - input string to parse
* @returns An array of tokens
* @internal
* @hidden
*/
export function simpleParser(input: string): Token[] {
const tokens: Token[] = []
let text = ''
let openTag = ''
let remainder = input
while (remainder.length > 0) {
if (!openTag && remainder[0] === '<') {
const match = matchOpenTag(remainder)
if (match) {
const tagName = match.groups!.tag
validateTagName(tagName)
if (text) {
tokens.push(...textTokenWithInterpolation(text))
text = ''
}
if (isSelfClosing(match[0])) {
tokens.push({type: 'tagOpen', selfClosing: true, name: tagName})
} else {
tokens.push({type: 'tagOpen', name: tagName})
openTag = tagName
}
remainder = remainder.substring(match[0].length)
} else {
// move on to next char
text += remainder[0]
remainder = remainder.substring(1)
}
} else if (openTag && remainder[0] === '<' && remainder[1] !== '<') {
const match = matchCloseTag(remainder)
if (match) {
const tagName = match.groups!.tag
if (remainder[1] !== '/') {
throw new Error(
`Expected closing tag for <${openTag}>, but found new opening tag <${tagName}>. Nested tags is not supported.`,
)
}
if (tagName !== openTag) {
throw new Error(
`Expected closing tag for <${openTag}>, but found closing tag </${tagName}> instead. Make sure each opening tag has a matching closing tag.`,
)
}
if (text) {
tokens.push(...textTokenWithInterpolation(text))
text = ''
}
tokens.push({type: 'tagClose', name: tagName})
openTag = ''
remainder = remainder.substring(match[0].length)
} else {
// move on to next char
text += remainder[0]
remainder = remainder.substring(1)
}
} else {
// move on to next char
text += remainder[0]
remainder = remainder.substring(1)
}
}
if (openTag) {
throw new Error(
`No matching closing tag for <${openTag}> found. Either make it self closing (e.g. "<${openTag}/>") or close it (e.g "<${openTag}>...</${openTag}>").`,
)
}
if (text) {
tokens.push(...textTokenWithInterpolation(text))
}
return tokens
}
function textTokenWithInterpolation(text: string): Token[] {
const tokens: Token[] = []
const interpolations = text.matchAll(TEMPLATE_RE)
let lastIndex = 0
for (const match of interpolations) {
if (typeof match.index === 'undefined') {
continue
}
const pre = text.slice(lastIndex, match.index)
if (pre.length > 0) {
tokens.push({type: 'text', text: pre})
}
tokens.push(parseInterpolation(match[0]))
lastIndex += pre.length + match[0].length
}
if (lastIndex < text.length) {
tokens.push({type: 'text', text: text.slice(lastIndex)})
}
return tokens
}
function parseInterpolation(interpolation: string): InterpolationToken {
const variable = interpolation.replace(/^\{\{|\}\}$/g, '').trim()
// Disallow formatters for interpolations when using the `Translate` function:
// Since we do not have a _key_ to format (only a substring), we do not want i18next to look up
// a matching string value for the "stub" value. We could potentially change this in the future,
// if we feel it is a useful feature.
if (variable.includes(',')) {
throw new Error(
`Interpolations with formatters are not supported when using <Translate>. Found "${variable}". Utilize "useTranslation" instead, or format the values passed to <Translate> ahead of time.`,
)
}
return {type: 'interpolation', variable}
}
function isSelfClosing(tag: string) {
return SELF_CLOSING_RE.test(tag)
}
function matchOpenTag(input: string) {
return input.match(OPEN_TAG_RE)
}
function matchCloseTag(input: string) {
return input.match(CLOSE_TAG_RE)
}
function validateTagName(tagName: string) {
const isValidComponentName = VALID_COMPONENT_NAME_RE.test(tagName)
if (isValidComponentName) {
return
}
const isValidHtmlTagName = VALID_HTML_TAG_NAME_RE.test(tagName)
if (isValidHtmlTagName) {
return
}
throw new Error(
tagName.trim() === tagName
? `Invalid tag "<${tagName}>". Tag names must be lowercase HTML tags or start with an uppercase letter and can only include letters and numbers.`
: `Invalid tag "<${tagName}>". No whitespace allowed in tags.`,
)
}