// Wikilink placeholder tokens for markdown round-trip import { advanceMarkdownFence, type MarkdownFence, type MarkdownFenceScanOptions } from './markdownFences' const WL_START = '\u2039WIKILINK:' const WL_END = '\u203A' const WL_RE = /\u2039WIKILINK:([^\u203A]+)\u203A/g const WIKILINK_RE = /\[\[([^\]]+)\]\]/g const TABLE_PLACEHOLDER_PREFIX = 'ENC:' const FORMAT_MARKERS = new Set(['*', '_', '`', '~']) type MarkdownSource = string type MarkdownLine = string type MarkdownLines = MarkdownLine[] type TableLineMap = boolean[] type PlaceholderPayload = string type WikilinkTarget = string type FrontmatterSplit = [MarkdownSource, MarkdownSource] type CharacterCount = number type LineIndex = number type TextOffset = number type TokenSequence = string type ParsedTextRange = { text: MarkdownSource, nextIndex: TextOffset } type MatchTargets = Set type WordCount = number type FenceMarker = MarkdownFence | null const WIKILINK_FENCE_SCAN_OPTIONS: MarkdownFenceScanOptions = { closingMustEndLine: false, maxLeadingSpaces: null, } /** Pre-process markdown: replace [[target]] with placeholder tokens */ export function preProcessWikilinks(md: MarkdownSource): MarkdownSource { const lines = md.split('\n') const tableLines = findMarkdownTableLines(lines) let fenceMarker: FenceMarker = null return lines.map((line, index) => { const nextFenceMarker = nextMarkdownFenceMarker(line, fenceMarker) if (nextFenceMarker !== fenceMarker || fenceMarker !== null) { fenceMarker = nextFenceMarker return line } return replaceWikilinksWithPlaceholders(line, { encodePayload: tableLines.at(index) ?? false }) }).join('\n') } // Minimal shape of a BlockNote block for wikilink processing interface BlockLike { content?: BlockContent children?: BlockLike[] [key: string]: unknown } type ScalarBlockContent = string | number | boolean | null | undefined type BlockContent = InlineItem[] | TableContentLike | Record | ScalarBlockContent interface InlineItem { type: string text?: string props?: Record content?: unknown [key: string]: unknown } interface TableContentLike { type: 'tableContent' rows?: TableRowLike[] [key: string]: unknown } interface TableRowLike { cells?: TableCellLike[] [key: string]: unknown } type TableCellLike = string | TableCellObjectLike interface TableCellObjectLike { content?: InlineItem[] [key: string]: unknown } type ContentTransform = (content: InlineItem[]) => InlineItem[] interface WikilinkReplacementOptions { encodePayload: boolean } function replaceWikilinksWithPlaceholders( line: MarkdownLine, options: WikilinkReplacementOptions, ): MarkdownLine { return line.replace(WIKILINK_RE, (_match, target) => wikilinkPlaceholder(target, options)) } function wikilinkPlaceholder( target: WikilinkTarget, options: WikilinkReplacementOptions, ): string { const payload = options.encodePayload ? `${TABLE_PLACEHOLDER_PREFIX}${encodeURIComponent(target)}` : target return `${WL_START}${payload}${WL_END}` } function decodePlaceholderPayload(payload: PlaceholderPayload): WikilinkTarget { if (!payload.startsWith(TABLE_PLACEHOLDER_PREFIX)) return payload const encoded = payload.slice(TABLE_PLACEHOLDER_PREFIX.length) try { return decodeURIComponent(encoded) } catch { return encoded } } function nextMarkdownFenceMarker(line: MarkdownLine, currentMarker: FenceMarker): FenceMarker { return advanceMarkdownFence(line, currentMarker, WIKILINK_FENCE_SCAN_OPTIONS) } function blankFencedCodeLines(content: MarkdownSource): MarkdownSource { let fenceMarker: FenceMarker = null return content.split('\n').map((line) => { const nextFenceMarker = nextMarkdownFenceMarker(line, fenceMarker) const shouldBlank = fenceMarker !== null || nextFenceMarker !== fenceMarker fenceMarker = nextFenceMarker return shouldBlank ? '' : line }).join('\n') } function findMarkdownTableLines(lines: MarkdownLines): TableLineMap { const tableLines = lines.map(() => false) let fenceMarker: FenceMarker = null for (let index = 0; index < lines.length - 1; index++) { const line = lines.at(index) const nextLine = lines.at(index + 1) if (line === undefined || nextLine === undefined) continue const nextFenceMarker = nextMarkdownFenceMarker(line, fenceMarker) if (fenceMarker !== null || nextFenceMarker !== fenceMarker) { fenceMarker = nextFenceMarker continue } if (!isPotentialTableRow(line) || !isMarkdownTableSeparator(nextLine)) { continue } tableLines.splice(index, 1, true) tableLines.splice(index + 1, 1, true) index = markTableBodyLines(lines, tableLines, index + 2) - 1 } return tableLines } function markTableBodyLines( lines: MarkdownLines, tableLines: TableLineMap, start: LineIndex, ): LineIndex { let index = start while (index < lines.length) { const line = lines.at(index) if (line === undefined || !isPotentialTableRow(line)) break tableLines.splice(index, 1, true) index++ } return index } function isPotentialTableRow(line: MarkdownLine): boolean { const trimmed = line.trim() return trimmed.includes('|') && trimmed !== '|' } function isMarkdownTableSeparator(line: MarkdownLine): boolean { const cells = splitTableCells(line) return cells.length > 1 && cells.every(isMarkdownTableSeparatorCell) } function splitTableCells(line: MarkdownLine): MarkdownLines { let trimmed = line.trim() if (trimmed.startsWith('|')) trimmed = trimmed.slice(1) if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1) return trimmed.split('|').map((cell) => cell.trim()).filter(Boolean) } function isMarkdownTableSeparatorCell(cell: MarkdownLine): boolean { return /^:?-+:?$/.test(cell) } /** Walk blocks recursively, applying a transform to each block's inline content */ function walkBlocks(blocks: unknown[], transform: ContentTransform, clone = false): unknown[] { let changed = false const nextBlocks = (blocks as BlockLike[]).map(block => { const result = walkBlock(block, transform, clone) if (result.changed) changed = true return result.block }) return changed ? nextBlocks : blocks } function walkBlock(block: BlockLike, transform: ContentTransform, clone: boolean): { block: BlockLike; changed: boolean } { const content = transformBlockContent(block.content, transform) const children = transformedBlockChildren(block, transform, clone) const changed = content !== block.content || children !== block.children if (!changed) return { block, changed: false } if (clone) return { block: { ...block, content, children }, changed: true } block.content = content block.children = children return { block, changed: true } } function transformedBlockChildren(block: BlockLike, transform: ContentTransform, clone: boolean): BlockLike[] | undefined { return Array.isArray(block.children) ? walkBlocks(block.children, transform, clone) as BlockLike[] : block.children } function transformBlockContent(content: BlockContent, transform: ContentTransform): BlockContent { if (Array.isArray(content)) return transform(content) if (isTableContent(content)) return transformTableContent(content, transform) return content } function isTableContent(content: BlockContent): content is TableContentLike { return Boolean( content && typeof content === 'object' && !Array.isArray(content) && (content as TableContentLike).type === 'tableContent' && Array.isArray((content as TableContentLike).rows), ) } function transformTableContent( content: TableContentLike, transform: ContentTransform, ): TableContentLike { let changed = false const rows = content.rows?.map((row) => { let rowChanged = false const cells = row.cells?.map((cell) => { const nextCell = transformTableCell(cell, transform) if (nextCell !== cell) rowChanged = true return nextCell }) if (!rowChanged) return row changed = true return { ...row, cells } }) if (!changed) return content return { ...content, rows, } } function transformTableCell(cell: TableCellLike, transform: ContentTransform): TableCellLike { if (typeof cell === 'string' || !Array.isArray(cell.content)) return cell const content = transform(cell.content) return content === cell.content ? cell : { ...cell, content } } function textSegment(item: InlineItem, text: MarkdownSource): InlineItem { return { ...item, text } } function wikilinkItem(target: WikilinkTarget): InlineItem { return { type: 'wikilink', props: { target }, content: undefined, } } /** Walk blocks and replace placeholder text with wikilink inline content */ export function injectWikilinks(blocks: unknown[]): unknown[] { return walkBlocks(blocks, expandWikilinksInContent) } /** * Deep-clone blocks and convert wikilink inline content back to [[target]] text. * This is the reverse of injectWikilinks — used before blocksToMarkdownLossy * so that wikilinks survive the markdown round-trip. */ export function restoreWikilinksInBlocks(blocks: unknown[]): unknown[] { return walkBlocks(blocks, collapseWikilinksInContent, true) } function expandWikilinksInContent(content: InlineItem[]): InlineItem[] { const result: InlineItem[] = [] for (const item of content) { result.push(...expandWikilinksInItem(item)) } return result } function expandWikilinksInItem(item: InlineItem): InlineItem[] { if (item.type !== 'text' || typeof item.text !== 'string' || !item.text.includes(WL_START)) return [item] const result: InlineItem[] = [] let lastIndex = 0 WL_RE.lastIndex = 0 let match = WL_RE.exec(item.text) while (match !== null) { if (match.index > lastIndex) result.push(textSegment(item, item.text.slice(lastIndex, match.index))) result.push(wikilinkItem(decodePlaceholderPayload(match[1]))) lastIndex = match.index + match[0].length match = WL_RE.exec(item.text) } if (lastIndex < item.text.length) result.push(textSegment(item, item.text.slice(lastIndex))) return result } function collapseWikilinksInContent(content: InlineItem[]): InlineItem[] { const result: InlineItem[] = [] let changed = false for (const item of content) { if (item.type === 'wikilink' && item.props?.target) { result.push({ type: 'text', text: `[[${item.props.target}]]` }) changed = true } else { result.push(item) } } return changed ? result : content } function frontmatterOpeningLength(content: MarkdownSource): CharacterCount | null { if (content.startsWith('---\r\n')) return 5 if (content.startsWith('---\n')) return 4 return null } function precedingLineEndingLength(value: MarkdownSource): CharacterCount { return value.startsWith('\r\n') ? 2 : value.startsWith('\n') ? 1 : 0 } function frontmatterCloseLength(value: MarkdownSource): CharacterCount { const lineEndingLength = precedingLineEndingLength(value) if (value.endsWith('\r\n')) return lineEndingLength + 5 if (value.endsWith('\n')) return lineEndingLength + 4 return lineEndingLength + 3 } /** Strip YAML frontmatter from markdown, returning [frontmatter, body] */ export function splitFrontmatter(content: MarkdownSource): FrontmatterSplit { const openLength = frontmatterOpeningLength(content) if (openLength === null) return ['', content] const afterOpen = content.slice(openLength) const close = afterOpen.match(/(?:^|\r?\n)---(?:\r?\n|$)/) if (!close || close.index === undefined) return ['', content] const to = openLength + close.index + frontmatterCloseLength(close[0]) return [content.slice(0, to), content.slice(to)] } /** Extract all outgoing wikilink targets from content. * Finds [[target]] and [[target|display]] patterns, returning just the target part. * Returns a sorted, deduplicated array. */ export function extractOutgoingLinks(content: MarkdownSource): WikilinkTarget[] { const links: WikilinkTarget[] = [] const re = /\[\[([^\]]+)\]\]/g const searchableContent = blankFencedCodeLines(content) for (const line of searchableContent.split('\n')) { re.lastIndex = 0 let match = re.exec(line) while (match !== null) { const inner = match[1] const pipeIdx = inner.indexOf('|') const target = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner if (target) links.push(target) match = re.exec(line) } } return [...new Set(links)].sort() } /** Extract the paragraph surrounding a [[target]] wikilink match from note content. * Searches for any target in the set, returns the first matching paragraph trimmed * to a max length. Returns null if no match found. */ export function extractBacklinkContext( content: MarkdownSource, matchTargets: MatchTargets, maxLength: CharacterCount = 120, ): MarkdownSource | null { const [, body] = splitFrontmatter(content) const searchableBody = blankFencedCodeLines(body) // Remove the H1 title line const withoutTitle = searchableBody.replace(/^\s*# [^\n]+\n?/, '') const paragraphs = withoutTitle.split(/\n{2,}/) for (const para of paragraphs) { const trimmed = para.trim() if (!trimmed) continue // Check if this paragraph contains a wikilink matching any target const re = /\[\[([^\]]+)\]\]/g let match = re.exec(trimmed) while (match !== null) { const inner = match[1] const pipeIdx = inner.indexOf('|') const target = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner if (matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')) { // Collapse whitespace and truncate const flat = trimmed.replace(/\s+/g, ' ') if (flat.length <= maxLength) return flat return flat.slice(0, maxLength - 1) + '\u2026' } match = re.exec(trimmed) } } return null } /** Check if a line is useful for snippet extraction (not blank, heading, code fence, or rule). */ function isSnippetLine(line: MarkdownLine): boolean { const t = line.trim() return t !== '' && !t.startsWith('#') && !t.startsWith('```') && !t.startsWith('---') } /** Strip leading list markers (*, -, +, 1.) from a line. */ function stripListMarker(line: MarkdownLine): MarkdownLine { const t = line.trimStart() for (const prefix of ['* ', '- ', '+ ']) { if (t.startsWith(prefix)) return t.slice(prefix.length) } const dotPos = t.indexOf('. ') if (isOrderedListMarker(t, dotPos)) { return t.slice(dotPos + 2) } return t } function isOrderedListMarker(line: MarkdownLine, dotPos: TextOffset): boolean { if (dotPos < 1 || dotPos > 3) return false return /^\d+$/.test(line.slice(0, dotPos)) } /** Remove the first H1 heading line, allowing leading blank lines. */ function removeH1Line(body: MarkdownSource): MarkdownSource { const lines = body.split('\n') for (let i = 0; i < lines.length; i++) { const line = lines.at(i) ?? '' if (line.trim().startsWith('# ')) return lines.slice(i + 1).join('\n') if (line.trim() !== '') return body } return body } /** Strip markdown formatting chars: bold, italic, code, strikethrough, and resolve links. */ function stripMarkdownChars(s: MarkdownSource): MarkdownSource { let result = '' let i = 0 while (i < s.length) { if (s.startsWith('[[', i)) { const parsed = readUntilSequence(s, i + 2, ']]') result += wikilinkDisplayText(parsed.text) i = parsed.nextIndex } else if (s.charAt(i) === '[') { const parsed = readUntilChar(s, i + 1, ']') result += parsed.text i = skipMarkdownLinkDestination(s, parsed.nextIndex) } else if (FORMAT_MARKERS.has(s.charAt(i))) { i++ } else { result += s.charAt(i) i++ } } return result } function readUntilSequence( value: MarkdownSource, start: TextOffset, sequence: TokenSequence, ): ParsedTextRange { const end = value.indexOf(sequence, start) if (end === -1) return { text: value.slice(start), nextIndex: value.length } return { text: value.slice(start, end), nextIndex: end + sequence.length } } function readUntilChar( value: MarkdownSource, start: TextOffset, char: TokenSequence, ): ParsedTextRange { const end = value.indexOf(char, start) if (end === -1) return { text: value.slice(start), nextIndex: value.length } return { text: value.slice(start, end), nextIndex: end + 1 } } function skipMarkdownLinkDestination(value: MarkdownSource, start: TextOffset): TextOffset { if (value.charAt(start) !== '(') return start const end = value.indexOf(')', start + 1) return end === -1 ? value.length : end + 1 } function wikilinkDisplayText(inner: WikilinkTarget): MarkdownSource { const pipe = inner.indexOf('|') return pipe === -1 ? inner : inner.slice(pipe + 1) } /** Extract sub-heading text (## , ### , etc.) stripped of the # prefix. */ function extractSubheadingText(line: MarkdownLine): MarkdownSource | null { const t = line.trim() const stripped = t.replace(/^#+/, '') if (stripped.length < t.length && stripped.startsWith(' ')) { const text = stripped.trim() return text || null } return null } /** Extract a snippet: first ~160 chars of body content, stripped of markdown. * Mirrors the Rust extract_snippet() logic for frontend use. */ export function extractSnippet(content: MarkdownSource): MarkdownSource { const [, body] = splitFrontmatter(content) const withoutH1 = removeH1Line(body) const clean = withoutH1.split('\n').filter(isSnippetLine).map(stripListMarker).join(' ') const stripped = stripMarkdownChars(clean).trim() if (stripped) { if (stripped.length <= 160) return stripped return stripped.slice(0, 160) + '...' } // Fallback: collect sub-heading text when no paragraph content exists const headingText = withoutH1.split('\n') .map(extractSubheadingText) .filter((t): t is MarkdownSource => t !== null) .join(' ') const headingStripped = stripMarkdownChars(headingText).trim() if (!headingStripped) return '' if (headingStripped.length <= 160) return headingStripped return headingStripped.slice(0, 160) + '...' } export function countWords(content: MarkdownSource): WordCount { const [, body] = splitFrontmatter(content) const withoutTitle = body.replace(/^\s*# [^\n]+\n?/, '') const withoutWikilinks = withoutTitle.replace(/\[\[[^\]]*\]\]/g, '') const text = withoutWikilinks.replace(/[#*_[\]`>~\-|]/g, '').trim() if (!text) return 0 return text.split(/\s+/).filter(Boolean).length }