mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 19:58:28 +01:00
188 lines
6.4 KiB
TypeScript
188 lines
6.4 KiB
TypeScript
import type { LinkDefinition } from './link-reference-definitions.ts'
|
|
import type { OpenHtmlBlock } from './html-blocks.ts'
|
|
import { atxHeading, claimsDirectiveLine, claimsPipeLine, closingCodeFence, isThematicBreak, openingCodeFence, setextHeadingLevel } from '../commonmark-grammar.ts'
|
|
import { openingHtmlBlock } from './html-blocks.ts'
|
|
import { readLinkDefinitions } from './link-reference-definitions.ts'
|
|
|
|
export type ClaimedConstruct = 'directive' | 'pipe-table'
|
|
|
|
export type LeafBlock =
|
|
| { construct: ClaimedConstruct; kind: 'claim' }
|
|
| { construct: string; kind: 'html' }
|
|
| { kind: 'code'; language: string; text: string }
|
|
| { kind: 'heading'; level: number; text: string }
|
|
| { kind: 'paragraph'; text: string }
|
|
| { kind: 'rule' }
|
|
|
|
export type ParsedBlocks = { blocks: LeafBlock[]; definitions: Map<string, LinkDefinition> }
|
|
|
|
type Walk = ParsedBlocks & { paragraph: string[] }
|
|
|
|
const blankLine = /^[ \t]*$/
|
|
const indentedCodeColumns = 4
|
|
const largestOpenerIndentation = 3
|
|
const tabStop = 4
|
|
|
|
export function parseBlocks(markdown: string): ParsedBlocks {
|
|
const lines = normalizeInput(markdown).split('\n')
|
|
const walk: Walk = { blocks: [], definitions: new Map(), paragraph: [] }
|
|
let index = 0
|
|
while (index < lines.length) {
|
|
const line = lines[index] ?? ''
|
|
if (blankLine.test(line)) {
|
|
closeParagraph(walk)
|
|
index += 1
|
|
continue
|
|
}
|
|
if (leadingColumns(line) >= indentedCodeColumns && walk.paragraph.length === 0) {
|
|
index = readIndentedCode(walk, lines, index)
|
|
continue
|
|
}
|
|
const opened = openBlock(walk, lines, index, line)
|
|
if (opened !== undefined) {
|
|
index = opened
|
|
continue
|
|
}
|
|
walk.paragraph.push(line.replace(/^[ \t]+/, ''))
|
|
index += 1
|
|
}
|
|
closeParagraph(walk)
|
|
return { blocks: walk.blocks, definitions: walk.definitions }
|
|
}
|
|
|
|
function openBlock(walk: Walk, lines: readonly string[], index: number, line: string): number | undefined {
|
|
const opener = removeColumns(line, largestOpenerIndentation)
|
|
const claimed = claimedConstruct(opener)
|
|
if (claimed !== undefined) {
|
|
closeParagraph(walk)
|
|
walk.blocks.push({ construct: claimed, kind: 'claim' })
|
|
return index + 1
|
|
}
|
|
if (readLineBlock(walk, opener)) return index + 1
|
|
const fence = openingCodeFence(opener)
|
|
if (fence !== undefined) {
|
|
closeParagraph(walk)
|
|
return readFencedCode(walk, lines, index + 1, fence, leadingColumns(line))
|
|
}
|
|
const html = openingHtmlBlock(opener, walk.paragraph.length > 0)
|
|
if (html === undefined) return undefined
|
|
closeParagraph(walk)
|
|
return readHtmlBlock(walk, lines, index, html)
|
|
}
|
|
|
|
function normalizeInput(markdown: string): string {
|
|
return markdown
|
|
.replace(/\r\n?/g, '\n')
|
|
.replaceAll('\u0000', '\ufffd')
|
|
.replace(/\n$/, '')
|
|
}
|
|
|
|
function claimedConstruct(opener: string): ClaimedConstruct | undefined {
|
|
if (claimsDirectiveLine(opener)) return 'directive'
|
|
return claimsPipeLine(opener) ? 'pipe-table' : undefined
|
|
}
|
|
|
|
// A setext underline over a paragraph the definitions emptied is no heading: it opens the next block.
|
|
function readLineBlock(walk: Walk, opener: string): boolean {
|
|
const level = walk.paragraph.length === 0 ? undefined : setextHeadingLevel(opener)
|
|
if (level !== undefined) {
|
|
const text = takeParagraph(walk)
|
|
if (text !== undefined) {
|
|
walk.blocks.push({ kind: 'heading', level, text })
|
|
return true
|
|
}
|
|
}
|
|
if (isThematicBreak(opener)) {
|
|
closeParagraph(walk)
|
|
walk.blocks.push({ kind: 'rule' })
|
|
return true
|
|
}
|
|
const heading = atxHeading(opener)
|
|
if (heading === undefined) return false
|
|
closeParagraph(walk)
|
|
walk.blocks.push({ kind: 'heading', level: heading.level, text: heading.text })
|
|
return true
|
|
}
|
|
|
|
function readFencedCode(walk: Walk, lines: readonly string[], start: number, fence: { info: string; marker: string }, indentation: number): number {
|
|
const collected: string[] = []
|
|
let index = start
|
|
while (index < lines.length) {
|
|
const line = lines[index] ?? ''
|
|
index += 1
|
|
if (closingCodeFence(removeColumns(line, largestOpenerIndentation), fence.marker)) break
|
|
collected.push(removeColumns(line, indentation))
|
|
}
|
|
walk.blocks.push({ kind: 'code', language: fence.info, text: collected.join('\n') })
|
|
return index
|
|
}
|
|
|
|
function readIndentedCode(walk: Walk, lines: readonly string[], start: number): number {
|
|
const collected: string[] = []
|
|
const held: string[] = []
|
|
let index = start
|
|
let end = start
|
|
while (index < lines.length) {
|
|
const line = lines[index] ?? ''
|
|
index += 1
|
|
if (blankLine.test(line)) {
|
|
held.push(removeColumns(line, indentedCodeColumns))
|
|
continue
|
|
}
|
|
if (leadingColumns(line) < indentedCodeColumns) break
|
|
collected.push(...held, removeColumns(line, indentedCodeColumns))
|
|
held.length = 0
|
|
end = index
|
|
}
|
|
walk.blocks.push({ kind: 'code', language: '', text: collected.join('\n') })
|
|
return end
|
|
}
|
|
|
|
function readHtmlBlock(walk: Walk, lines: readonly string[], start: number, html: OpenHtmlBlock): number {
|
|
let index = start
|
|
while (index < lines.length) {
|
|
const line = lines[index] ?? ''
|
|
if (html.closer === undefined && blankLine.test(line)) break
|
|
index += 1
|
|
if (html.closer !== undefined && html.closer.test(line)) break
|
|
}
|
|
walk.blocks.push({ construct: html.construct, kind: 'html' })
|
|
return index
|
|
}
|
|
|
|
function closeParagraph(walk: Walk): void {
|
|
const text = takeParagraph(walk)
|
|
if (text !== undefined) walk.blocks.push({ kind: 'paragraph', text })
|
|
}
|
|
|
|
function takeParagraph(walk: Walk): string | undefined {
|
|
const text = readLinkDefinitions(walk.definitions, walk.paragraph.join('\n'))
|
|
walk.paragraph = []
|
|
return text === '' ? undefined : text
|
|
}
|
|
|
|
function leadingColumns(line: string): number {
|
|
let columns = 0
|
|
for (const character of line) {
|
|
if (character === ' ') columns += 1
|
|
else if (character === '\t') columns += tabStop - (columns % tabStop)
|
|
else break
|
|
}
|
|
return columns
|
|
}
|
|
|
|
// CommonMark's tab stops: a tab the cut splits gives the columns it holds past the cut back as spaces.
|
|
function removeColumns(line: string, columns: number): string {
|
|
let removed = 0
|
|
let index = 0
|
|
while (removed < columns && index < line.length) {
|
|
const character = line.charAt(index)
|
|
if (character !== ' ' && character !== '\t') break
|
|
const width = character === ' ' ? 1 : tabStop - (removed % tabStop)
|
|
index += 1
|
|
if (removed + width > columns) return ' '.repeat(removed + width - columns) + line.slice(index)
|
|
removed += width
|
|
}
|
|
return line.slice(index)
|
|
}
|