import type { EditorView } from '@codemirror/view'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { Element, MarkdownConfig } from '@lezer/markdown'; import { styleTags, tags } from '@lezer/highlight'; import { Content } from '../content'; import { selectAll } from 'hast-util-select'; function fuzzyMatch(text: string, search: string): number { const textLower = text.toLowerCase().normalize('NFC'); const searchLower = search.toLowerCase().normalize('NFC'); let searchIndex = 0; let score = 0; for (let i = 0; i < textLower.length && searchIndex < searchLower.length; i++) { if (textLower[i] === searchLower[searchIndex]) { score += 1; if (i === searchIndex) score += 2; // Bonus for sequential match searchIndex++; } } return searchIndex === searchLower.length ? score : 0; } export const wikilink: MarkdownConfig = { defineNodes: [ 'Wikilink', //Whole group 'WikilinkMeta', //Meta characters ([[ & ]]) 'WikilinkHref', //Link 'WikilinkTitle', //Title (always visible) ], parseInline: [{ name: 'Wikilink', before: 'Link', parse(cx, next, pos) { // 91 == '[' if (next !== 91 || cx.slice(pos, pos + 1).charCodeAt(0) !== 91) return -1; const match = /!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/.exec(cx.slice(pos, cx.end)); if(!match) return -1; const start = pos, children: Element[] = [], end = start + match[0].length; if(match[0] === '[[]]') return end; children.push(cx.elt('WikilinkMeta', start, start + 2)); if(match[1] && !match[2] && !match[3]) //Link only { children.push(cx.elt('WikilinkTitle', start + 2, end - 2)); } else if(!match[1] && match[2] && match[3]) //Hash and title { children.push(cx.elt('WikilinkHref', start + 2, start + 2 + match[2].length)); children.push(cx.elt('WikilinkMeta', start + 2 + match[2].length, start + 2 + match[2].length + 1)); children.push(cx.elt('WikilinkTitle', start + 2 + match[2].length + 1, start + 2 + match[2].length + match[3].length)); } else if(!match[1] && !match[2] && match[3]) //Hash only { children.push(cx.elt('WikilinkTitle', start + 2, end - 2)); } else if(match[1] && match[2] && !match[3]) //Link and hash { children.push(cx.elt('WikilinkHref', start + 2, start + 2 + match[1].length)); children.push(cx.elt('WikilinkTitle', start + 2 + match[1].length, start + 2 + match[1].length + match[2].length)); } else if(match[1] && !match[2] && match[3]) //Link and title { children.push(cx.elt('WikilinkHref', start + 2, start + 2 + match[1].length)); children.push(cx.elt('WikilinkMeta', start + 2 + match[1].length, start + 2 + match[1].length + 1)); children.push(cx.elt('WikilinkTitle', start + 2 + match[1].length + 1, start + 2 + match[1].length + match[3].length)); } else if(match[1] && match[2] && match[3]) //Link, hash and title { children.push(cx.elt('WikilinkHref', start + 2, start + 2 + match[1].length + match[2].length)); children.push(cx.elt('WikilinkMeta', start + 2 + match[1].length + match[2].length, start + 2 + match[1].length + match[2].length + 1)); children.push(cx.elt('WikilinkTitle', start + 2 + match[1].length + match[2].length + 1, start + 2 + match[1].length + match[2].length + match[3].length)); } children.push(cx.elt('WikilinkMeta', end - 2, end)); return cx.addElement(cx.elt('Wikilink', start, end, children)); }, }], props: [ styleTags({ 'Wikilink': tags.special(tags.content), 'WikilinkMeta': tags.meta, 'WikilinkHref': tags.link, 'WikilinkTitle': tags.special(tags.link), }) ] }; export const autocompletion = (context: CompletionContext): CompletionResult | Promise | null => { const header = context.matchBefore(/\[\[[^\[\]\|\#]+#[^\[\]\|\#]*/); if(!header || (header.from === header.to && !context.explicit)) { const word = context.matchBefore(/\[\[[\w\s-]*/); if (!word || (word.from === word.to && !context.explicit)) return null; const options = Object.values(Content.files).filter(e => e.type !== 'folder'); return { from: word.from + 2, options: options.map(e => ({ label: e.title, detail: e.path, apply: (view, completion, from, to) => { const closed = view.state.sliceDoc(from, to + 2).endsWith(']]'); view.dispatch({ changes: { from: from - 2, to: to, insert: closed ? `[[${completion.detail}` : `[[${completion.detail}]]` }, selection: { anchor: from + (completion.detail?.length ?? 0) } }); }, type: 'text', })), commitCharacters: ['#', '|'], validFor: /^[\[\w\s-]*$/, } } else { const path = header.text.match(/^\[\[([^\[\]\|\#]+)#/); if(!path || !path[1]) return null; const content = Content.getFromPath(path[1]); if(!content || content.type !== 'markdown') return null; return (async () => { const headers = selectAll('h1, h2, h3, h4, h5, h6', await useMarkdown().parse((await Content.getContent(content.id))!.content as string)); return { from: header.from + path[1]!.length + 3, options: headers.map(e => ({ label: e.properties.id as string, apply: (view, completion, from, to) => { const closed = view.state.sliceDoc(from, to + 2).endsWith(']]'); view.dispatch({ changes: { from: from, to: to, insert: closed ? `${completion.label}` : `${completion.label}]]` }, selection: { anchor: from + (completion.label?.length ?? 0) } }); }, type: 'text', })), commitCharacters: ['#', '|'], validFor: new RegExp(`\\[\\[${path[1]}#[^\[\]\|\#]*`), }; })(); } };