Read the emphasis, the links and the lone image CommonMark spells (#34)
CI / gate (push) Successful in 5s
CI / gate (push) Successful in 5s
This commit was merged in pull request #34.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { backslashEscape, decodeTextEscapes, holdsControlCharacter } from './commonmark-grammar.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../result.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 {
|
||||
return raw
|
||||
.replace(/^[ \t\n]+|[ \t\n]+$/g, '')
|
||||
.replace(/[ \t\n]+/g, ' ')
|
||||
.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, path: ConvertErrorPath): Result<string> {
|
||||
if (holdsControlCharacter(href)) return failure('unspellable-link-destination', 'a link destination holds a control character', path)
|
||||
if (href.includes('\\')) return failure('unspellable-link-destination', 'no canonical escape spells a backslash in a link destination', path)
|
||||
if (holdsEntityReference(href)) {
|
||||
return failure('unspellable-link-destination', 'a link destination holds an entity reference that decodes on the way back', path)
|
||||
}
|
||||
if (href.includes(' ')) {
|
||||
if (/[<>]/.test(href)) {
|
||||
return failure('unspellable-link-destination', 'no canonical escape spells an angle bracket beside a space in a link destination', path)
|
||||
}
|
||||
return success(`<${href}>`)
|
||||
}
|
||||
if (href.startsWith('<')) return failure('unspellable-link-destination', 'a bare link destination cannot begin with an angle bracket', path)
|
||||
return success(escapeUnbalanced(href))
|
||||
}
|
||||
|
||||
export function spellTitle(title: string, path: ConvertErrorPath): Result<string> {
|
||||
if (/[\n\r\\]/.test(title)) {
|
||||
return failure('unspellable-link-title', 'no canonical escape spells a backslash or newline in a link title', path)
|
||||
}
|
||||
if (holdsEntityReference(title)) return failure('unspellable-link-title', 'a link title holds an entity reference that decodes on the way back', path)
|
||||
return success(` "${title.replaceAll('"', '\\"')}"`)
|
||||
}
|
||||
|
||||
function escapeUnbalanced(href: string): string {
|
||||
const open: number[] = []
|
||||
const unbalanced = new Set<number>()
|
||||
for (let index = 0; index < href.length; index += 1) {
|
||||
const character = href.charAt(index)
|
||||
if (character === '(') open.push(index)
|
||||
if (character === ')' && open.pop() === undefined) unbalanced.add(index)
|
||||
}
|
||||
for (const index of open) unbalanced.add(index)
|
||||
let spelled = ''
|
||||
for (let index = 0; index < href.length; index += 1) spelled += (unbalanced.has(index) ? '\\' : '') + href.charAt(index)
|
||||
return spelled
|
||||
}
|
||||
Reference in New Issue
Block a user