Files
adf-codec/src/markdown/link-syntax.ts
T

122 lines
5.1 KiB
TypeScript

import { backslashEscape, decodeTextEscapes, holdsControlCharacter } from './commonmark-grammar.ts'
import { holdsEntityReference } from './entity-references.ts'
export type LinkDefinition = { destination: string; title?: string }
export type LinkPart = { length: number; value: string }
const bracketedDestination = /<((?:[^\n<>\\]|\\[^\n])*)>/y
const linkLabel = /\[((?:[^[\]\\]|\\[\s\S]){0,999})\]/y
const titleClosers: Readonly<Record<string, string>> = { '"': '"', "'": "'", '(': ')' }
// The label is matched by its normalized raw text, escapes and all.
export function readLabel(text: string, offset: number): LinkPart | undefined {
linkLabel.lastIndex = offset
const value = linkLabel.exec(text)?.[1]
return value === undefined ? undefined : { length: value.length + 2, value }
}
export function normalizeLabel(raw: string): string {
const collapsed = raw.replace(/[ \t\n]+/g, ' ')
const start = collapsed.startsWith(' ') ? 1 : 0
const end = collapsed.endsWith(' ') ? collapsed.length - 1 : collapsed.length
return collapsed.slice(start, end).toLowerCase()
}
export function readDestination(text: string, offset: number): LinkPart | undefined {
bracketedDestination.lastIndex = offset
const bracketed = bracketedDestination.exec(text)?.[0]
if (bracketed !== undefined) return { length: bracketed.length, value: decodeTextEscapes(bracketed.slice(1, -1)) }
if (text.charAt(offset) === '<') return undefined
let depth = 0
let index = offset
while (index < text.length) {
const character = text.charAt(index)
if (character === ' ' || holdsControlCharacter(character)) break
if (backslashEscape(text, index) !== undefined) {
index += 2
continue
}
if (character === '(') depth += 1
if (character === ')') {
depth -= 1
if (depth < 0) break
}
index += 1
}
return index <= offset ? undefined : { length: index - offset, value: decodeTextEscapes(text.slice(offset, index)) }
}
// `offset` sits on the `(` a link text closes into.
export function readInlineTarget(text: string, offset: number): { definition: LinkDefinition; length: number } | undefined {
let index = skipLinkWhitespace(text, offset + 1)
let destination = ''
if (text.charAt(index) !== ')') {
const read = readDestination(text, index)
if (read === undefined) return undefined
destination = read.value
index += read.length
}
const afterDestination = index
index = skipLinkWhitespace(text, index)
const title = index > afterDestination ? readTitle(text, index) : undefined
if (title !== undefined) index = skipLinkWhitespace(text, index + title.length)
if (text.charAt(index) !== ')') return undefined
return { definition: title === undefined ? { destination } : { destination, title: title.value }, length: index + 1 - offset }
}
export function readTitle(text: string, offset: number): LinkPart | undefined {
const opener = text.charAt(offset)
const closer = titleClosers[opener]
if (closer === undefined) return undefined
let index = offset + 1
while (index < text.length) {
const character = text.charAt(index)
if (backslashEscape(text, index) !== undefined) {
index += 2
continue
}
if (character === closer) return { length: index + 1 - offset, value: decodeTextEscapes(text.slice(offset + 1, index)) }
if (character === opener) return undefined
index += 1
}
return undefined
}
// The label, the destination and the title each take at most one line ending with them.
export function skipLinkWhitespace(text: string, offset: number): number {
const rest = text.slice(offset)
return offset + rest.length - rest.replace(/^[ \t]*\n?[ \t]*/, '').length
}
export function spellDestination(href: string): string | undefined {
if (holdsControlCharacter(href) || href.includes('\\') || holdsEntityReference(href)) return undefined
if (href.includes(' ')) return /[<>]/.test(href) ? undefined : `<${href}>`
return href.startsWith('<') ? undefined : escapeUnbalanced(href, '(', ')')
}
export function spellLinkTarget(href: string, title: string | undefined): string | undefined {
const destination = spellDestination(href)
if (destination === undefined || title === undefined) return destination
const spelledTitle = spellTitle(title)
return spelledTitle === undefined ? undefined : `${destination === '' ? '<>' : destination}${spelledTitle}`
}
export function escapeUnbalanced(spelling: string, opener: string, closer: string): string {
const open: number[] = []
const unbalanced = new Set<number>()
for (let index = 0; index < spelling.length; index += backslashEscape(spelling, index) === undefined ? 1 : 2) {
const character = spelling.charAt(index)
if (character === opener) open.push(index)
if (character === closer && open.pop() === undefined) unbalanced.add(index)
}
for (const index of open) unbalanced.add(index)
let spelled = ''
for (let index = 0; index < spelling.length; index += 1) spelled += (unbalanced.has(index) ? '\\' : '') + spelling.charAt(index)
return spelled
}
function spellTitle(title: string): string | undefined {
return /[\n\r\\]/.test(title) || holdsEntityReference(title) ? undefined : ` "${title.replaceAll('"', '\\"')}"`
}