mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 23:44:22 +00:00
293 lines
6.9 KiB
TypeScript
293 lines
6.9 KiB
TypeScript
import { visit } from 'unist-util-visit';
|
|
import { lookupFileByName, getFileUrl } from '../api/notes';
|
|
import { InlineContainerType, MARKDOWN_REGEX } from '../types/markdown';
|
|
import { Node } from 'unist';
|
|
import { Parent } from 'unist';
|
|
import { Text } from 'mdast';
|
|
|
|
/**
|
|
* Represents a wiki link match from the regex
|
|
*/
|
|
interface WikiLinkMatch {
|
|
fullMatch: string;
|
|
isImage: string;
|
|
fileName: string;
|
|
displayText: string;
|
|
heading?: string;
|
|
index: number;
|
|
}
|
|
|
|
/**
|
|
* Node replacement information for processing
|
|
*/
|
|
interface ReplacementInfo {
|
|
matches: WikiLinkMatch[];
|
|
parent: Parent;
|
|
index: number;
|
|
}
|
|
|
|
/**
|
|
* Properties for link nodes
|
|
*/
|
|
interface LinkNodeProps {
|
|
style?: {
|
|
color?: string;
|
|
textDecoration?: string;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Link node with data properties
|
|
*/
|
|
interface LinkNode extends Node {
|
|
type: 'link';
|
|
url: string;
|
|
children: Node[];
|
|
data?: {
|
|
hProperties?: LinkNodeProps;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Image node
|
|
*/
|
|
interface ImageNode extends Node {
|
|
type: 'image';
|
|
url: string;
|
|
alt?: string;
|
|
title?: string;
|
|
}
|
|
|
|
/**
|
|
* Text node
|
|
*/
|
|
interface TextNode extends Node {
|
|
type: 'text';
|
|
value: string;
|
|
}
|
|
|
|
/**
|
|
* Creates a text node with the given value
|
|
*/
|
|
function createTextNode(value: string): TextNode {
|
|
return {
|
|
type: 'text',
|
|
value,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a link node for files that don't exist
|
|
*/
|
|
function createNotFoundLink(
|
|
fileName: string,
|
|
displayText: string,
|
|
baseUrl: string
|
|
): LinkNode {
|
|
return {
|
|
type: 'link',
|
|
url: `${baseUrl}/notfound/${encodeURIComponent(fileName)}`,
|
|
children: [createTextNode(displayText)],
|
|
data: {
|
|
hProperties: { style: { color: 'red', textDecoration: 'underline' } },
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a link node for existing files
|
|
*/
|
|
function createFileLink(
|
|
filePath: string,
|
|
displayText: string,
|
|
heading: string | undefined,
|
|
baseUrl: string
|
|
): LinkNode {
|
|
const url = heading
|
|
? `${baseUrl}/internal/${encodeURIComponent(filePath)}#${encodeURIComponent(
|
|
heading
|
|
)}`
|
|
: `${baseUrl}/internal/${encodeURIComponent(filePath)}`;
|
|
|
|
return {
|
|
type: 'link',
|
|
url,
|
|
children: [createTextNode(displayText)],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates an image node
|
|
*/
|
|
function createImageNode(
|
|
workspaceName: string,
|
|
filePath: string,
|
|
displayText: string
|
|
): ImageNode {
|
|
return {
|
|
type: 'image',
|
|
url: getFileUrl(workspaceName, filePath),
|
|
alt: displayText,
|
|
title: displayText,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Adds markdown extension to a filename if it doesn't have one
|
|
*/
|
|
function addMarkdownExtension(fileName: string): string {
|
|
if (fileName.includes('.')) {
|
|
return fileName;
|
|
}
|
|
return `${fileName}.md`;
|
|
}
|
|
|
|
/**
|
|
* Determines if a node type can contain inline content
|
|
*/
|
|
function canContainInline(type: string): boolean {
|
|
return Object.values(InlineContainerType).includes(
|
|
type as InlineContainerType
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Plugin for processing wiki-style links in markdown
|
|
*/
|
|
export function remarkWikiLinks(workspaceName: string) {
|
|
return async function transformer(tree: Node): Promise<void> {
|
|
if (!workspaceName) {
|
|
console.warn('No workspace ID provided to remarkWikiLinks plugin');
|
|
return;
|
|
}
|
|
|
|
const baseUrl: string = window.API_BASE_URL;
|
|
const replacements = new Map<Text, ReplacementInfo>();
|
|
|
|
// Find all wiki links
|
|
visit(tree, 'text', function (node: Text, index: number, parent: Parent) {
|
|
const regex = MARKDOWN_REGEX.WIKILINK;
|
|
let match: RegExpExecArray | null;
|
|
const matches: WikiLinkMatch[] = [];
|
|
|
|
while ((match = regex.exec(node.value)) !== null) {
|
|
const [fullMatch, isImage, innerContent] = match;
|
|
let fileName: string;
|
|
let displayText: string;
|
|
let heading: string | undefined;
|
|
|
|
const pipeIndex: number = innerContent.indexOf('|');
|
|
const hashIndex: number = innerContent.indexOf('#');
|
|
|
|
if (pipeIndex !== -1) {
|
|
displayText = innerContent.slice(pipeIndex + 1).trim();
|
|
fileName = innerContent.slice(0, pipeIndex).trim();
|
|
} else {
|
|
displayText = innerContent;
|
|
fileName = innerContent;
|
|
}
|
|
|
|
if (hashIndex !== -1 && (pipeIndex === -1 || hashIndex < pipeIndex)) {
|
|
heading = fileName.slice(hashIndex + 1).trim();
|
|
fileName = fileName.slice(0, hashIndex).trim();
|
|
}
|
|
|
|
matches.push({
|
|
fullMatch,
|
|
isImage,
|
|
fileName,
|
|
displayText,
|
|
heading,
|
|
index: match.index,
|
|
});
|
|
}
|
|
|
|
if (matches.length > 0) {
|
|
replacements.set(node, { matches, parent, index });
|
|
}
|
|
});
|
|
|
|
// Process all matches
|
|
for (const [node, { matches, parent }] of replacements) {
|
|
const newNodes: (LinkNode | ImageNode | TextNode)[] = [];
|
|
let lastIndex: number = 0;
|
|
|
|
for (const match of matches) {
|
|
// Add text before the match
|
|
if (match.index > lastIndex) {
|
|
newNodes.push({
|
|
type: 'text',
|
|
value: node.value.slice(lastIndex, match.index),
|
|
});
|
|
}
|
|
|
|
try {
|
|
const lookupFileName: string = match.isImage
|
|
? match.fileName
|
|
: addMarkdownExtension(match.fileName);
|
|
|
|
const paths: string[] = await lookupFileByName(
|
|
workspaceName,
|
|
lookupFileName
|
|
);
|
|
|
|
if (paths && paths.length > 0) {
|
|
const filePath: string = paths[0];
|
|
if (match.isImage) {
|
|
newNodes.push(
|
|
createImageNode(workspaceName, filePath, match.displayText)
|
|
);
|
|
} else {
|
|
newNodes.push(
|
|
createFileLink(
|
|
filePath,
|
|
match.displayText,
|
|
match.heading,
|
|
baseUrl
|
|
)
|
|
);
|
|
}
|
|
} else {
|
|
newNodes.push(
|
|
createNotFoundLink(match.fileName, match.displayText, baseUrl)
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.debug('File lookup failed:', match.fileName, error);
|
|
newNodes.push(
|
|
createNotFoundLink(match.fileName, match.displayText, baseUrl)
|
|
);
|
|
}
|
|
|
|
lastIndex = match.index + match.fullMatch.length;
|
|
}
|
|
|
|
// Add any remaining text
|
|
if (lastIndex < node.value.length) {
|
|
newNodes.push({
|
|
type: 'text',
|
|
value: node.value.slice(lastIndex),
|
|
});
|
|
}
|
|
|
|
// Replace nodes in parent
|
|
if (parent && canContainInline(parent.type)) {
|
|
const nodeIndex: number = parent.children.indexOf(node);
|
|
if (nodeIndex !== -1) {
|
|
parent.children.splice(nodeIndex, 1, ...newNodes);
|
|
}
|
|
} else {
|
|
// Wrap in paragraph for other types
|
|
const paragraph: Parent = {
|
|
type: 'paragraph',
|
|
children: newNodes,
|
|
};
|
|
const nodeIndex: number = parent.children.indexOf(node);
|
|
if (nodeIndex !== -1) {
|
|
parent.children.splice(nodeIndex, 1, paragraph);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|