hololake-system-architecture/product-source/hololake-platform/src/utils/blockNoteDirectMarkdown.ts

423 lines
14 KiB
TypeScript

import { calloutKind, TOLARIA_CALLOUT_BLOCK_TYPE } from './calloutMarkdown'
interface TextStyles {
[style: string]: string | boolean | undefined
}
interface InlineItem {
type?: string
text?: string
href?: string
props?: Record<string, string | undefined>
styles?: TextStyles
content?: InlineItem[]
[key: string]: unknown
}
interface TableCellLike {
content?: InlineItem[]
[key: string]: unknown
}
type TableCellValue = string | TableCellLike
interface TableRowLike {
cells?: TableCellValue[]
[key: string]: unknown
}
interface TableContentLike {
type?: string
rows?: TableRowLike[]
[key: string]: unknown
}
interface BlockLike {
type?: string
content?: InlineItem[] | TableContentLike | unknown
props?: Record<string, string | number | boolean | undefined>
children?: BlockLike[]
[key: string]: unknown
}
export interface BlockNoteDirectMarkdownMetrics {
blockCount: number
cacheHits: number
cacheMisses: number
durationMs: number
fallbackReason: string | null
}
export interface BlockNoteDirectMarkdownResult {
markdown: string
metrics: BlockNoteDirectMarkdownMetrics
supported: boolean
}
export interface DirectMarkdownCapableSerializer {
blocksToMarkdownLossy: (blocks: unknown[]) => string
blocksToMarkdownDirect?: (blocks: unknown[]) => BlockNoteDirectMarkdownResult
__tolariaDirectMarkdownCache?: WeakMap<object, Map<string, string>>
__tolariaLastDirectMarkdownMetrics?: BlockNoteDirectMarkdownMetrics
}
type MarkdownLinePrefix = {
marker: string
indent: string
}
interface SerializeContext {
cache?: WeakMap<object, Map<string, string>>
cacheHits: number
cacheMisses: number
fallbackReason: string | null
numberedStack: number[]
}
const DIRECT_MARKDOWN_METHOD = 'blocksToMarkdownDirect'
const ESCAPE_TEXT_RE = /([\\`*_{}<>()#!])/g
const ESCAPE_TABLE_CELL_RE = /[|\n\r]/g
const TEXT_CONTENT_BLOCK_TYPES = new Set([
'bulletListItem',
'checkListItem',
'numberedListItem',
'paragraph',
])
const MEDIA_BLOCK_TYPES = new Set(['audio', 'file', 'image', 'video'])
type BlockMarkdownHandler = (block: BlockLike, context: SerializeContext) => string | null
function now(): number {
return globalThis.performance?.now?.() ?? Date.now()
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function blockObject(value: unknown): BlockLike | null {
return isRecord(value) ? value as BlockLike : null
}
function contentArray(content: unknown): InlineItem[] {
return Array.isArray(content) ? content as InlineItem[] : []
}
function tableContent(content: unknown): TableContentLike | null {
return isRecord(content) && content.type === 'tableContent' && Array.isArray(content.rows)
? content as TableContentLike
: null
}
function escapeText(text: string): string {
return text.replace(ESCAPE_TEXT_RE, '\\$1')
}
function escapeLinkTarget(target: string): string {
return target.includes(' ') || target.includes(')') ? `<${target.replace(/>/g, '%3E')}>` : target
}
function inlineText(item: InlineItem): string {
if (item.type === 'text') return item.text ?? ''
if (item.type === 'link') return linkMarkdown(item)
if (item.type === 'wikilink') return wikilinkMarkdown(item)
if (item.type === 'mathInline') return item.props?.latex ? `$${item.props.latex}$` : ''
if (Array.isArray(item.content)) return serializeInlineContent(item.content)
return ''
}
function linkMarkdown(item: InlineItem): string {
const content = Array.isArray(item.content)
? serializeInlineContent(item.content)
: escapeText(item.text ?? item.props?.title ?? item.props?.href ?? '')
const href = item.props?.href ?? item.href
return href ? `[${content}](${escapeLinkTarget(href)})` : content
}
function wikilinkMarkdown(item: InlineItem): string {
const target = item.props?.target
return target ? `[[${target}]]` : ''
}
function wrapInlineMarkdown(text: string, marker: string): string {
if (!text) return text
return `${marker}${text}${marker}`
}
function styledTextMarkdown(item: InlineItem): string {
let text = escapeText(item.text ?? '')
const styles = item.styles ?? {}
if (styles.code === true) return codeSpan(item.text ?? '')
if (styles.bold === true) text = wrapInlineMarkdown(text, '**')
if (styles.italic === true) text = wrapInlineMarkdown(text, '*')
if (styles.strike === true) text = wrapInlineMarkdown(text, '~~')
return text
}
function codeSpan(text: string): string {
const marker = text.includes('`') ? '``' : '`'
const needsPadding = marker === '``' && (text.startsWith('`') || text.endsWith('`'))
return needsPadding ? `${marker} ${text} ${marker}` : `${marker}${text}${marker}`
}
function serializeInlineItem(item: InlineItem): string {
return item.type === 'text' ? styledTextMarkdown(item) : inlineText(item)
}
export function serializeInlineContent(content: InlineItem[] | undefined): string {
return content?.map(serializeInlineItem).join('') ?? ''
}
function blockPrefix(block: BlockLike, depth: number, context: SerializeContext): MarkdownLinePrefix | null {
const indent = ' '.repeat(depth)
if (block.type === 'numberedListItem') {
const next = context.numberedStack[depth] ?? Number(block.props?.start ?? 1)
context.numberedStack[depth] = next + 1
return { indent, marker: `${next}. ` }
}
context.numberedStack[depth] = 1
if (block.type === 'bulletListItem') return { indent, marker: '- ' }
if (block.type === 'checkListItem') return { indent, marker: block.props?.checked === true ? '- [x] ' : '- [ ] ' }
return null
}
function advanceCachedBlockContext(block: BlockLike, depth: number, context: SerializeContext): void {
if (block.type === 'numberedListItem') {
const next = context.numberedStack[depth] ?? Number(block.props?.start ?? 1)
context.numberedStack[depth] = next + 1
} else {
context.numberedStack[depth] = 1
}
context.numberedStack.length = depth + 1
}
function prependLinePrefix(markdown: string, prefix: MarkdownLinePrefix): string {
const lines = markdown.split('\n')
return lines.map((line, index) => (
index === 0
? `${prefix.indent}${prefix.marker}${line}`
: `${prefix.indent} ${line}`
)).join('\n')
}
function codeBlockMarkdown(block: BlockLike): string {
const language = typeof block.props?.language === 'string' ? block.props.language : ''
const code = serializeInlineContent(contentArray(block.content)).replace(/\n$/u, '')
const fence = code.includes('```') ? '~~~' : '```'
return `${fence}${language === 'text' ? '' : language}\n${code}\n${fence}`
}
function mediaLabel(name: string, url: string): string {
return name || url.split('/').pop() || url
}
function mediaUrl(block: BlockLike): string {
return typeof block.props?.url === 'string' ? block.props.url : ''
}
function mediaMarkdown(block: BlockLike): string {
const url = mediaUrl(block)
const name = typeof block.props?.name === 'string' ? block.props.name : ''
if (!url) return name
const label = mediaLabel(name, url)
return block.type === 'image'
? `![${escapeText(label)}](${escapeLinkTarget(url)})`
: `[${escapeText(label)}](${escapeLinkTarget(url)})`
}
function quoteMarkdown(block: BlockLike): string {
const text = serializeInlineContent(contentArray(block.content))
return text.split('\n').map(line => `> ${line}`).join('\n')
}
function calloutMarkdown(block: BlockLike): string {
const kind = calloutKind(block.props?.kind)
const text = serializeInlineContent(contentArray(block.content)) || 'Write a note'
return `[!${kind}] ${text}`
.split('\n')
.map(line => `> ${line}`)
.join('\n')
}
function tableCellMarkdown(cell: TableCellValue): string {
const text = typeof cell === 'string'
? cell
: serializeInlineContent(contentArray(cell.content))
return text.replace(ESCAPE_TABLE_CELL_RE, character => character === '|' ? '\\|' : ' ')
}
function tableMarkdown(block: BlockLike): string | null {
const content = tableContent(block.content)
const rows = content?.rows ?? []
if (rows.length === 0) return ''
const cellRows = rows.map(row => row.cells?.map(tableCellMarkdown) ?? [])
const width = Math.max(...cellRows.map(row => row.length), 0)
if (width === 0) return ''
const normalizedRows = cellRows.map(row => Array.from({ length: width }, (_, index) => row[index] ?? ''))
const [head, ...body] = normalizedRows
return [
`| ${head.join(' | ')} |`,
`| ${Array.from({ length: width }, () => '---').join(' | ')} |`,
...body.map(row => `| ${row.join(' | ')} |`),
].join('\n')
}
function inlineBlockMarkdown(block: BlockLike): string {
return serializeInlineContent(contentArray(block.content))
}
function headingMarkdown(block: BlockLike): string {
const level = Math.max(1, Math.min(6, Number(block.props?.level ?? 1)))
return `${'#'.repeat(level)} ${serializeInlineContent(contentArray(block.content))}`.trimEnd()
}
function unsupportedBlockMarkdown(block: BlockLike, context: SerializeContext): null {
context.fallbackReason = typeof block.type === 'string' ? `unsupported:${block.type}` : 'unsupported:unknown'
return null
}
const BLOCK_MARKDOWN_HANDLERS: Record<string, BlockMarkdownHandler> = {
codeBlock: codeBlockMarkdown,
divider: () => '---',
heading: headingMarkdown,
quote: quoteMarkdown,
table: tableMarkdown,
[TOLARIA_CALLOUT_BLOCK_TYPE]: calloutMarkdown,
}
function blockMarkdownWithoutChildren(block: BlockLike, context: SerializeContext): string | null {
if (typeof block.type !== 'string') return unsupportedBlockMarkdown(block, context)
if (TEXT_CONTENT_BLOCK_TYPES.has(block.type)) return inlineBlockMarkdown(block)
if (MEDIA_BLOCK_TYPES.has(block.type)) return mediaMarkdown(block)
return BLOCK_MARKDOWN_HANDLERS[block.type]?.(block, context) ?? unsupportedBlockMarkdown(block, context)
}
function serializeChildren(block: BlockLike, depth: number, context: SerializeContext): string {
const children = Array.isArray(block.children) ? block.children : []
if (children.length === 0) return ''
const childDepth = depth + 1
context.numberedStack[childDepth] = 1
const markdown = serializeBlockList(children, childDepth, context)
context.numberedStack.length = childDepth
return markdown
}
function blockCacheKey(block: BlockLike, depth: number, context: SerializeContext): string {
return [
depth,
block.type ?? '',
context.numberedStack[depth] ?? '',
].join(':')
}
function cachedBlockMarkdown(block: BlockLike, cacheKey: string, context: SerializeContext): string | null {
const cached = context.cache?.get(block as object)?.get(cacheKey)
if (cached === undefined) return null
context.cacheHits++
return cached
}
function storeCachedBlockMarkdown(
block: BlockLike,
cacheKey: string,
markdown: string,
context: SerializeContext,
): void {
if (!context.cache) return
const existing = context.cache.get(block as object)
if (existing) {
existing.set(cacheKey, markdown)
return
}
context.cache.set(block as object, new Map([[cacheKey, markdown]]))
}
function renderUncachedBlock(block: BlockLike, depth: number, context: SerializeContext): string | null {
context.cacheMisses++
const ownMarkdown = blockMarkdownWithoutChildren(block, context)
if (ownMarkdown === null) return null
const prefix = blockPrefix(block, depth, context)
const ownWithPrefix = prefix ? prependLinePrefix(ownMarkdown, prefix) : ownMarkdown
const childMarkdown = serializeChildren(block, depth, context)
return childMarkdown ? `${ownWithPrefix}\n${childMarkdown}` : ownWithPrefix
}
function serializeBlock(block: BlockLike, depth: number, context: SerializeContext): string | null {
const cacheKey = blockCacheKey(block, depth, context)
const cached = cachedBlockMarkdown(block, cacheKey, context)
if (cached !== null) {
advanceCachedBlockContext(block, depth, context)
return cached
}
const markdown = renderUncachedBlock(block, depth, context)
if (markdown === null) return null
storeCachedBlockMarkdown(block, cacheKey, markdown, context)
return markdown
}
function serializeBlockList(blocks: BlockLike[], depth: number, context: SerializeContext): string {
const chunks: string[] = []
for (const value of blocks) {
const block = blockObject(value)
if (!block) {
context.fallbackReason = 'unsupported:non-object-block'
return ''
}
const markdown = serializeBlock(block, depth, context)
if (markdown === null) return ''
if (markdown) chunks.push(markdown)
}
return chunks.join('\n\n')
}
export function blocksToMarkdownDirect(
blocks: unknown[],
cache?: WeakMap<object, Map<string, string>>,
): BlockNoteDirectMarkdownResult {
const startedAt = now()
const context: SerializeContext = {
cache,
cacheHits: 0,
cacheMisses: 0,
fallbackReason: null,
numberedStack: [],
}
const markdown = serializeBlockList(blocks as BlockLike[], 0, context)
const durationMs = now() - startedAt
return {
markdown,
supported: context.fallbackReason === null,
metrics: {
blockCount: blocks.length,
cacheHits: context.cacheHits,
cacheMisses: context.cacheMisses,
durationMs,
fallbackReason: context.fallbackReason,
},
}
}
export function installBlockNoteDirectMarkdown(editor: DirectMarkdownCapableSerializer): void {
if (typeof editor.blocksToMarkdownDirect === 'function') return
const cache = new WeakMap<object, Map<string, string>>()
editor.__tolariaDirectMarkdownCache = cache
editor.blocksToMarkdownDirect = (blocks: unknown[]) => {
const result = blocksToMarkdownDirect(blocks, cache)
editor.__tolariaLastDirectMarkdownMetrics = result.metrics
return result
}
}
export function serializeBlockNoteMarkdown(
editor: DirectMarkdownCapableSerializer,
blocks: unknown[],
): string {
const direct = editor[DIRECT_MARKDOWN_METHOD]?.(blocks)
if (direct?.supported) return direct.markdown
return editor.blocksToMarkdownLossy(blocks)
}