feat: publish HoloLake model-native living system source
This commit is contained in:
parent
6ad10edde1
commit
c395dd3a99
2467 changed files with 615073 additions and 0 deletions
552
product-source/hololake-platform/src/utils/wikilinks.ts
Normal file
552
product-source/hololake-platform/src/utils/wikilinks.ts
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
// 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<WikilinkTarget>
|
||||
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<string, unknown> | ScalarBlockContent
|
||||
|
||||
interface InlineItem {
|
||||
type: string
|
||||
text?: string
|
||||
props?: Record<string, string>
|
||||
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
|
||||
}
|
||||
Loading…
Reference in a new issue