refactor: replace custom markdown pipeline with Obsidian's native MarkdownRenderer.

Fix reactive issue for web and chat mode display.

Rewrites StreamingMarkdownService to use Obsidian's built-in MarkdownRenderer
instead of the unified/remark/rehype/KaTeX stack, eliminating ~700 lines of
custom markdown processing, CSS, and streaming state management. Internal link
handling now relies on Obsidian's native .internal-link class and data-href
attributes. The streaming split-point algorithm (freeze completed blocks, re-render
the live tail) is preserved.
This commit is contained in:
Andrew Beal 2026-05-25 16:25:22 +01:00
parent 857adbbf1c
commit aed4caf347
11 changed files with 645 additions and 3394 deletions

View file

@ -8,21 +8,16 @@
import { Role } from "Enums/Role";
import type { ConversationContent } from "Conversations/ConversationContent";
import { tick } from "svelte";
import { Selector } from "Enums/Selector";
import { Exception } from "Helpers/Exception";
import { getOuterHeight, setElementIcon } from "Helpers/ElementHelper";
export let messages: ConversationContent[] = [];
export let currentThought: string | null = null;
export let isSubmitting: boolean = false;
export let chatContainer: HTMLDivElement;
export let currentStreamingMessageId: string | null = null;
export function resetChatArea() {
autoScroll = true;
messageElements = [];
lastProcessedContent.clear();
currentStreamFinalized = false;
if (chatAreaPaddingElement) {
chatAreaPaddingElement.style.padding = "0px";
}
@ -115,8 +110,6 @@
let streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
let messageElements: { element: HTMLElement, index: number, role: Role }[] = [];
let lastProcessedContent: Map<string, string> = new Map<string, string>();
let currentStreamFinalized: boolean = false;
function getGreetingByTime(): string {
const hour = new Date().getHours();
@ -139,66 +132,19 @@
}
}
function updateMessageContent(messageId: string, content: string, isCurrentlyStreaming: boolean) {
if (isCurrentlyStreaming) {
streamingMarkdownService.streamChunk(messageId, content);
currentStreamFinalized = false;
} else if (!currentStreamFinalized) {
streamingMarkdownService.finalizeStream(messageId, content);
currentStreamFinalized = true;
}
}
// Process static messages (user messages and initial load)
function getStaticHTML(message: ConversationContent): string {
if (message.role === Role.User) {
return `<div>${message.content}</div>`;
}
// For assistant messages, check if this specific message is currently streaming
const messageId = message.timestamp.getTime().toString();
const isCurrentlyStreaming = currentStreamingMessageId === messageId;
// For assistant messages that aren't streaming, use traditional parsing
if (!isCurrentlyStreaming) {
const content = message.getDisplayContent();
if (message.errorType) {
return `<div class="${Selector.ErrorSelector}">${content}</div>`;
function messageRenderAction(element: HTMLElement, message: ConversationContent) {
streamingMarkdownService.render(message.getDisplayContent(), element);
return {
update(newMessage: ConversationContent) {
streamingMarkdownService.render(newMessage.getDisplayContent(), element, !isSubmitting);
}
try {
return streamingMarkdownService.formatText(content) || `<div>${content}</div>`;
} catch (error) {
Exception.log(error);
return `<div>${content}</div>`;
}
}
return ""; // Streaming messages will be handled by the streaming service
}
function streamingAction(element: HTMLElement, messageId: string) {
streamingMarkdownService.initializeStream(messageId, element);
};
}
function trackingAction(element: HTMLElement, { index, role }: { index: number, role: Role }) {
messageElements.push({ index: index, element: element, role: role });
}
function observeResize(element: HTMLElement) {
const observer = new ResizeObserver(() => {
updateChatAreaLayout("smooth");
});
observer.observe(element);
return {
destroy() {
observer.disconnect();
}
};
}
// decide if we should be auto scrolling
function handleScroll() {
if (!chatContainer) {
@ -228,29 +174,6 @@
}
}
// Track streaming messages and update them incrementally
$: {
messages.forEach((message) => {
if (message.role !== Role.User) {
const messageId = message.timestamp.getTime().toString();
const lastContent = lastProcessedContent.get(messageId) || "";
// Only update if content has changed
if (message.content !== lastContent) {
// Check if this specific message is currently streaming
const isCurrentlyStreaming = currentStreamingMessageId === messageId;
const content = message.getDisplayContent();
// Only process through streaming service if actively streaming
if (isCurrentlyStreaming) {
updateMessageContent(messageId, content, isCurrentlyStreaming);
}
lastProcessedContent.set(messageId, content);
}
}
});
}
$: {
if (messages.length === 0 && chatAreaPaddingElement) {
chatAreaPaddingElement.style.padding = "0px";
@ -262,14 +185,14 @@
{#if messages.length > 0}
<div class="top-fade"></div>
{/if}
<div class="chat-area" bind:this={chatContainer} on:scroll={handleScroll} use:observeResize>
<div class="chat-area" bind:this={chatContainer} on:scroll={handleScroll}>
{#each messages as message, index}
{@const content = message.getDisplayContent()}
{#if message.shouldDisplayContent && content.trim() !== ""}
{#if message.role === Role.User}
<div class="message-container {Role.User}" use:trackingAction={{ index, role: Role.User }}>
<div class="message-bubble {Role.User}">
<div class="message-text-user-container content-fade-in" contenteditable="false">
<div class="message-text-user-container" contenteditable="false">
<div class="message-text-user">
{@html content}
</div>
@ -297,12 +220,8 @@
{@const messageId = message.timestamp.getTime().toString()}
<div class="message-container {Role.Assistant}" use:trackingAction={{ index, role: Role.Assistant }}>
<div class="message-bubble {Role.Assistant}">
<div class="markdown-content content-fade-in {currentStreamingMessageId === messageId ? "streaming" : ""}">
{#if currentStreamingMessageId === messageId}
<div use:streamingAction={messageId} class="streaming-content"></div>
{:else}
{@html getStaticHTML(message)}
{/if}
<div class="markdown-content">
<div use:messageRenderAction={message} class="streaming-content"></div>
</div>
</div>
</div>

View file

@ -64,6 +64,9 @@
let inputMode: InputMode = InputMode.Normal;
let questionResolver: ((answer: string) => void) | null = null;
let webSearchActive: boolean = settingsService.settings.enableWebSearch;
let editsAllowed: boolean = chatModeAllowsEdits(chatMode);
let countdownIntervalId: ReturnType<typeof setInterval> | null = null;
let countdownSecondsRemaining: number = 0;
let inputInitialHeight: number = 0;
@ -72,7 +75,13 @@
const diffClosedRef: EventRef = eventService.on(Event.DiffClosed, () => { inputMode = InputMode.Normal; focusInput(); });
const rateLimitCountdownRef: EventRef = eventService.on(Event.RateLimitCountdown, (delayMs: number) => { startCountdown(delayMs); });
settingsService.subscribeToSettingsChanged(componentToken, () => chatMode = settingsService.settings.chatMode);
settingsService.subscribeToSettingsChanged(componentToken, () => {
chatMode = settingsService.settings.chatMode;
editsAllowed = chatModeAllowsEdits(chatMode);
if (chatModeButton){
setIcon(chatModeButton, iconForChatMode(chatMode));
}
});
onMount(async () => {
userInstructionActive = (await aiPrompt.userInstruction()).trim() !== "";
@ -294,13 +303,16 @@
}
function toggleWebSearch() {
const newState = !settingsService.settings.enableWebSearch;
settingsService.updateSettings(settings => {
settings.enableWebSearch = !settingsService.settings.enableWebSearch;
settings.enableWebSearch = newState;
});
webSearchActive = newState;
}
function toggleChatModeSelectionArea() {
chatModeSelectionAreaActive = !chatModeSelectionAreaActive;
}
async function handleKeydown(e: KeyboardEvent) {
@ -559,10 +571,10 @@
<button
id="web-search-button"
class:input-button-highlight={settingsService.settings.enableWebSearch}
class:input-button-highlight={webSearchActive}
bind:this={webSearchButton}
on:click={toggleWebSearch}
aria-label={settingsService.settings.enableWebSearch ? Copy.ButtonTurnOffWebSearch : Copy.ButtonTurnOnWebSearch}>
aria-label={webSearchActive ? Copy.ButtonTurnOffWebSearch : Copy.ButtonTurnOnWebSearch}>
</button>
<div
@ -599,7 +611,7 @@
<button
id="chat-mode-button"
class:input-button-highlight={chatModeAllowsEdits(chatMode)}
class:input-button-highlight={editsAllowed}
bind:this={chatModeButton}
on:click={toggleChatModeSelectionArea}
disabled={isSubmitting}

View file

@ -8,7 +8,6 @@
import { Conversation } from "Conversations/Conversation";
import type VaultkeeperAIPlugin from "main";
import { openPluginSettings } from "Helpers/Helpers";
import { Selector } from "Enums/Selector";
import type { WorkSpaceService } from "Services/WorkSpaceService";
import type { ChatService } from "Services/ChatService";
import type { ConversationFileSystemService } from "Services/ConversationFileSystemService";
@ -19,7 +18,6 @@
import ChatPlanArea from "./ChatPlanArea.svelte";
import type { ExecutionPlanStore } from "Stores/ExecutionPlanStore";
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
import { HTMLService } from "Services/HTMLService";
import { AITool, fromString } from "Enums/AITool";
const plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
@ -29,7 +27,6 @@
const workSpaceService: WorkSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
const conversationService: ConversationFileSystemService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
const streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
const htmlService: HTMLService = Resolve<HTMLService>(Services.HTMLService);
const abortService: AbortService = Resolve<AbortService>(Services.AbortService);
let chatContainer: HTMLDivElement;
@ -39,7 +36,6 @@
let hasNoApiKey = false;
let isSubmitting = false;
let busyPlanning = false;
let currentStreamingMessageId: string | null = null;
let conversation: Conversation = new Conversation();
let attachments: Attachment[] = [];
@ -63,21 +59,19 @@
async function handleLinkClick(evt: MouseEvent) {
const target = evt.target as HTMLElement;
const link = target.closest(`.${Selector.MarkDownLink}`) as HTMLAnchorElement | null;
const link = target.closest('.internal-link') as HTMLAnchorElement | null;
if (!link) {
return;
return;
}
const href = link.getAttribute('href');
if (!href || !href.startsWith('#/page/')) {
const notePath = link.getAttribute('data-href');
if (!notePath) {
return;
}
evt.preventDefault();
evt.stopPropagation();
const encodedPath = href.replace('#/page/', '');
const notePath = decodeURIComponent(encodedPath);
await workSpaceService.openNote(notePath);
}
@ -107,9 +101,8 @@
attachments = [];
chatArea.resetAutoScroll();
},
onStreamingUpdate: (streamingId) => {
onStreamingUpdate: () => {
conversation = conversation;
currentStreamingMessageId = streamingId;
chatArea.updateChatAreaLayout("smooth");
},
onThoughtUpdate: (thought) => {
@ -141,8 +134,7 @@
},
onUserQuestion: async (question) => {
const displayEl = createEl("div");
const formattedHtml = streamingMarkdownService.formatText(question);
htmlService.setHTMLContent(displayEl, formattedHtml);
await streamingMarkdownService.render(question, displayEl, true);
chatInput.setDisplayItem(displayEl);
return new Promise<string>((resolve) => {
chatInput.enterQuestionMode(resolve);
@ -176,7 +168,6 @@
conversationService.resetCurrentConversation();
isSubmitting = false;
currentStreamingMessageId = null;
currentThought = null;
chatService.onNameChanged?.("");
@ -187,7 +178,6 @@
conversation.contents = [];
isSubmitting = false;
currentStreamingMessageId = null;
currentThought = null;
chatArea.resetChatArea();
@ -210,8 +200,7 @@
<ChatPlanArea executionPlanState={executionPlanStore.executionPlanState} {busyPlanning}/>
<div id="chat-container">
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isSubmitting bind:chatContainer
currentStreamingMessageId={currentStreamingMessageId}/>
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isSubmitting bind:chatContainer/>
</div>
<ChatInput

View file

@ -41,18 +41,27 @@
let selectedTopic: number = initialTopic;
let title: string = topics[selectedTopic].title;
let content: string = streamingMarkdownService.formatText(topics[selectedTopic].content);
let contentVisible: boolean = true;
function selectTopic(topicNumber: number) {
title = "";
content = "";
contentVisible = false;
selectedTopic = topicNumber;
setTimeout(() => {
title = topics[selectedTopic].title;
content = streamingMarkdownService.formatText(topics[selectedTopic].content);
contentVisible = true;
}, 200);
}
function helpContentAction(element: HTMLElement, topic: number) {
streamingMarkdownService.render(topics[topic].content, element, true);
return {
update(newTopic: number) {
streamingMarkdownService.render(topics[newTopic].content, element, true);
}
};
}
$: if (closeButton) {
setIcon(closeButton, 'circle-x');
}
@ -60,22 +69,19 @@
async function handleLinkClick(evt: MouseEvent) {
const target = evt.target as HTMLElement;
// Check for both internal wikilinks and regular markdown links
const link = target.closest('a') as HTMLAnchorElement | null;
const link = target.closest('.internal-link') as HTMLAnchorElement | null;
if (!link) {
return;
}
const href = link.getAttribute('href');
if (!href || !href.startsWith('#/page/')) {
const notePath = link.getAttribute('data-href');
if (!notePath) {
return;
}
evt.preventDefault();
evt.stopPropagation();
const encodedPath = href.replace('#/page/', '');
const notePath = decodeURIComponent(encodedPath);
await workSpaceService.openNote(notePath);
onClose();
}
@ -176,9 +182,9 @@
</div>
</div>
<div class="help-modal-content" bind:this={contentContainer}>
{#if content !== ""}
{#if contentVisible}
<div transition:fade={{ duration: 100 }} use:helpContentAction={selectedTopic}></div>
<div transition:fade={{ duration: 100 }}>
{@html content}
{#if selectedTopic === 1}
<a
href="{plugin.manifest.authorUrl}/vaultkeeper-ai"

View file

@ -151,7 +151,7 @@ export abstract class BaseAgent {
this.debugService?.log("StreamError", `AI stream error for ${agentType}: ${chunk.errorType}`);
conversationContent.content = chunk.error;
conversationContent.errorType = chunk.errorType;
callbacks.onStreamingUpdate(null);
callbacks.onStreamingUpdate();
break;
}
@ -197,13 +197,13 @@ export abstract class BaseAgent {
}
if (conversationContent.content?.trim() !== "") {
callbacks.onStreamingUpdate(conversationContent.timestamp.getTime().toString());
callbacks.onStreamingUpdate();
} else {
conversationContent.shouldDisplayContent = false;
}
}
callbacks.onStreamingUpdate(null);
callbacks.onStreamingUpdate();
return { toolCall: capturedToolCall, shouldContinue: capturedShouldContinue };
}

View file

@ -20,7 +20,7 @@ import type { ChatMode } from "Enums/ChatMode";
export interface IChatServiceCallbacks {
onSubmit: () => void;
onStreamingUpdate: (streamingMessageId: string | null) => void;
onStreamingUpdate: () => void;
onThoughtUpdate: (thought: string | null) => void;
onToolCallStarted: (toolName: string) => void;
onPlanningStarted: () => void;
@ -85,7 +85,7 @@ export class ChatService {
conversation.contents.push(conversationContent);
await this.saveConversation(conversation);
callbacks.onStreamingUpdate(conversationContent.timestamp.getTime().toString());
callbacks.onStreamingUpdate();
let namingPromise: Promise<void> | undefined;
if (firstMessage) {
@ -107,7 +107,7 @@ export class ChatService {
await this.saveConversation(conversation);
callbacks.onSubmit();
callbacks.onStreamingUpdate(null);
callbacks.onStreamingUpdate();
await this.mainAgent.runMainAgent(conversation, chatMode, callbacks);

View file

@ -1,240 +1,103 @@
import { unified, type Processor } from "unified";
import remarkParse from "remark-parse";
import remarkMath from "remark-math";
import remarkGfm from "remark-gfm";
import remarkEmoji from "remark-emoji";
import remarkRehype from "remark-rehype";
import rehypeKatex from "rehype-katex";
import rehypeHighlight from "rehype-highlight";
import rehypeStringify from "rehype-stringify";
import wikiLinkPlugin from "remark-wiki-link";
import type { Root as MdastRoot } from "mdast";
import type { Root as HastRoot } from "hast";
import { Component, MarkdownRenderer } from "obsidian";
import type VaultkeeperAIPlugin from "main";
import { Resolve } from "./DependencyService";
import { Services } from "./Services";
import { Selector } from "Enums/Selector";
import type { HTMLService } from "./HTMLService";
import type { VaultCacheService } from "./VaultCacheService";
import { Exception } from "Helpers/Exception";
interface IStreamingState {
element: HTMLElement;
buffer: string;
lastProcessedLength: number;
isComplete: boolean;
interface RenderState {
frozenContainer: HTMLElement;
liveContainer: HTMLElement;
frozenUpTo: number;
frozenText: string;
component: Component;
}
export class StreamingMarkdownService {
private readonly htmlService: HTMLService = Resolve<HTMLService>(Services.HTMLService);
private readonly vaultCacheService: VaultCacheService = Resolve<VaultCacheService>(Services.VaultCacheService);
private readonly plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
private readonly states = new WeakMap<HTMLElement, RenderState>();
private readonly processor: Processor<MdastRoot, MdastRoot, HastRoot, HastRoot, string>;
private streamingStates: Map<string, IStreamingState> = new Map<string, IStreamingState>();
constructor() {
this.processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkEmoji)
.use(remarkMath)
.use(wikiLinkPlugin, {
permalinks: this.vaultCacheService.wikiLinks.links,
wikiLinkClassName: Selector.MarkDownLink,
pageResolver: (pageName: string) => [pageName],
hrefTemplate: (permalink: string) => `#/page/${encodeURIComponent(permalink)}`
})
.use(remarkRehype, {
allowDangerousHtml: false
})
.use(rehypeKatex)
.use(rehypeHighlight, {
detect: true,
plainText: ["txt", "text"],
aliases: {
javascript: ["js", "jsx"],
typescript: ["ts", "tsx"],
python: ["py"],
markdown: ["md", "mdx"],
shell: ["bash", "sh", "zsh"]
}
})
.use(rehypeStringify, {
allowDangerousHtml: false,
allowDangerousCharacters: false,
closeSelfClosing: true
});
}
public formatText(text: string): string {
try {
const preprocessed = this.preprocessContent(text);
const result = this.processor.processSync(preprocessed);
return String(result);
} catch (error) {
Exception.warn(`Markdown processing failed:\n${Exception.messageFrom(error)}`);
return this.getFallbackHTML(text);
}
}
public initializeStream(messageId: string, container: HTMLElement) {
this.htmlService.clearElement(container);
this.streamingStates.set(messageId, {
element: container,
buffer: "",
lastProcessedLength: 0,
isComplete: false
});
}
public streamChunk(messageId: string, fullText: string) {
const state = this.streamingStates.get(messageId);
if (!state || state.isComplete) {
public async render(markdown: string, container: HTMLElement, isFinal: boolean = false): Promise<void> {
if (isFinal) {
const existing = this.states.get(container);
if (existing) {
existing.component.unload();
this.states.delete(container);
}
const component = new Component();
component.load();
container.empty();
await MarkdownRenderer.render(this.plugin.app, markdown, container, "", component);
component.unload();
return;
}
state.buffer = fullText;
const state = this.getOrCreateState(container);
const splitPoint = this.findSplitPoint(markdown);
const frozenCandidate = markdown.slice(0, splitPoint);
const liveTail = markdown.slice(splitPoint);
// Use debounced rendering for better performance
this.debouncedRender(messageId);
}
private renderTimeouts = new Map<string, ReturnType<typeof activeWindow.setTimeout>>();
private debouncedRender(messageId: string, immediate: boolean = false) {
const existingTimeout = this.renderTimeouts.get(messageId);
if (existingTimeout) {
window.clearTimeout(existingTimeout);
if (frozenCandidate.length > state.frozenUpTo) {
const newSlice = frozenCandidate.slice(state.frozenUpTo);
const tempDiv = activeDocument.createElement("div");
await MarkdownRenderer.render(this.plugin.app, newSlice, tempDiv, "", state.component);
while (tempDiv.firstChild) {
state.frozenContainer.appendChild(tempDiv.firstChild);
}
state.frozenUpTo = frozenCandidate.length;
state.frozenText = frozenCandidate;
}
const render = () => {
const state = this.streamingStates.get(messageId);
if (!state) {
return;
}
try {
const html = this.formatText(state.buffer);
this.htmlService.setHTMLContent(state.element, html);
state.lastProcessedLength = state.buffer.length;
} catch (error) {
Exception.warn(`Streaming render failed:\n${Exception.messageFrom(error)}`);
}
this.renderTimeouts.delete(messageId);
};
if (immediate) {
render();
} else {
const timeout = window.setTimeout(render, 50); // 50ms debounce
this.renderTimeouts.set(messageId, timeout);
state.liveContainer.empty();
if (liveTail.trim()) {
await MarkdownRenderer.render(this.plugin.app, liveTail, state.liveContainer, "", state.component);
}
}
public finalizeStream(messageId: string, fullText: string) {
const state = this.streamingStates.get(messageId);
if (!state) {
return;
private getOrCreateState(container: HTMLElement): RenderState {
if (this.states.has(container)) {
return this.states.get(container)!;
}
state.isComplete = true;
state.buffer = fullText;
// Final render without debounce
this.debouncedRender(messageId, true);
// Cleanup
this.streamingStates.delete(messageId);
const timeout = this.renderTimeouts.get(messageId);
if (timeout) {
window.clearTimeout(timeout);
this.renderTimeouts.delete(messageId);
}
container.empty();
const frozenContainer = activeDocument.createElement("div");
const liveContainer = activeDocument.createElement("div");
container.appendChild(frozenContainer);
container.appendChild(liveContainer);
const component = new Component();
component.load();
const state: RenderState = { frozenContainer, liveContainer, frozenUpTo: 0, frozenText: "", component };
this.states.set(container, state);
return state;
}
private preprocessContent(content: string): string {
// Simplified and safer preprocessing
return content
// Normalize line endings
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
// Convert LaTeX delimiters
.replace(/\\\[([\s\S]*?)\\\]/g, (_match: string, math: string) => {
// Ensure math blocks are on their own lines
return "\n$$\n" + math.trim() + "\n$$\n";
})
.replace(/\\\(([\s\S]*?)\\\)/g, "$$$1$$")
// Ensure headers have blank lines before them (but not at start)
.replace(/([^\n])\n(#{1,6}\s)/g, "$1\n\n$2")
// Collapse excessive newlines but preserve intentional spacing
.replace(/\n{4,}/g, "\n\n\n")
// Clean up list formatting - ensure consistent spacing
.replace(/^(\s*)([*+-]|\d+\.)\s+/gm, "$1$2 ")
// Ensure task list checkboxes are properly formatted
.replace(/^(\s*)([*+-])\s*\[([ x])\]/gm, "$1$2 [$3]");
}
// Returns the char index at which the "live tail" begins.
// Everything before this index is safe to freeze (complete blocks).
private findSplitPoint(text: string): number {
const lastDoubleNewline = text.lastIndexOf("\n\n");
if (lastDoubleNewline === -1) {
return 0;
}
private getFallbackHTML(text: string): string {
// Improved fallback with basic markdown support
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const lines = escaped.split("\n");
const html: string[] = [];
let inList = false;
let inCodeBlock = false;
for (const line of lines) {
if (line.startsWith("```")) {
if (inCodeBlock) {
html.push("</code></pre>");
inCodeBlock = false;
} else {
html.push("<pre><code>");
inCodeBlock = true;
}
continue;
}
if (inCodeBlock) {
html.push(line + "\n");
continue;
}
// Basic list support
if (/^[*+-]\s/.test(line)) {
if (!inList) {
html.push("<ul>");
inList = true;
}
html.push(`<li>${line.substring(2)}</li>`);
} else if (inList && line.trim() === "") {
html.push("</ul>");
inList = false;
} else {
if (inList) {
html.push("</ul>");
inList = false;
}
// Basic formatting
const formatted = line
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
.replace(/`(.+?)`/g, "<code>$1</code>");
if (line.trim()) {
html.push(`<p>${formatted}</p>`);
}
const candidate = text.slice(0, lastDoubleNewline);
// Count unmatched opening code fences in the candidate.
// If odd, there's an open fence — walk back to before it.
const fenceRegex = /^```/gm;
let fenceCount = 0;
let lastOpenFenceIndex = -1;
for (const match of candidate.matchAll(fenceRegex)) {
fenceCount++;
if (fenceCount % 2 === 1) {
lastOpenFenceIndex = match.index!;
}
}
if (inList) html.push("</ul>");
if (inCodeBlock) html.push("</code></pre>");
return html.join("");
if (fenceCount % 2 === 1) {
// Odd number of fences — open code block, back up to before it
return lastOpenFenceIndex > 0 ? lastOpenFenceIndex : 0;
}
return lastDoubleNewline;
}
}

View file

@ -258,10 +258,6 @@ body {
--table-border: var(--table-border-color);
--table-header-background: var(--table-header-background);
--hr-color: var(--hr-color);
--checkbox-bg: var(--background-primary);
--checkbox-border: var(--background-modifier-border);
--checkbox-checked-bg: var(--interactive-accent);
--footnote-border: var(--background-modifier-border);
--alt-background-primary: color-mix(
in hsl shorter hue,
var(--background-primary) 75%,
@ -278,703 +274,6 @@ body {
var(--background-modifier-border-hover) 50%,
var(--background-primary) 40%
);
counter-reset: katexEqnNo mmlEqnNo;
}
/* ============================== */
/* GFM Task Lists */
/* ============================== */
/* Task list container */
.contains-task-list,
ul.contains-task-list {
list-style: none;
padding-left: 0;
}
/* Task list items */
.task-list-item,
li.task-list-item {
position: relative;
list-style: none;
padding-left: 1.75em;
margin-left: 0;
}
/* Task list checkboxes */
.task-list-item > input[type="checkbox"],
.task-list-item input[type="checkbox"] {
position: absolute;
left: 0;
top: 0.25em;
margin: 0;
width: 1em;
height: 1em;
cursor: pointer;
background-color: var(--checkbox-bg);
border: 1px solid var(--checkbox-border);
border-radius: 3px;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
/* Checked state */
.task-list-item > input[type="checkbox"]:checked,
.task-list-item input[type="checkbox"]:checked {
background-color: var(--checkbox-checked-bg);
border-color: var(--checkbox-checked-bg);
}
/* Checkmark for checked boxes */
.task-list-item > input[type="checkbox"]:checked::after,
.task-list-item input[type="checkbox"]:checked::after {
content: "✓";
display: block;
text-align: center;
color: var(--text-on-accent);
font-size: 0.75em;
line-height: 1em;
font-weight: bold;
}
/* Disabled state */
.task-list-item > input[type="checkbox"]:disabled,
.task-list-item input[type="checkbox"]:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* Nested task lists */
.task-list-item .contains-task-list {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
/* ============================== */
/* GFM Footnotes */
/* ============================== */
/* Footnote references */
sup[data-footnote-ref],
a[data-footnote-ref] {
font-weight: bold;
text-decoration: none;
color: var(--link-color);
}
sup[data-footnote-ref]::before {
content: "[";
}
sup[data-footnote-ref]::after {
content: "]";
}
/* Footnote section */
section[data-footnotes],
.footnotes {
margin-top: 2em;
padding-top: 1em;
border-top: 1px solid var(--footnote-border);
font-size: 0.9em;
}
/* Footnote list */
section[data-footnotes] ol,
.footnotes ol {
padding-left: 1.5em;
}
/* Footnote back references */
a[data-footnote-backref] {
text-decoration: none;
margin-left: 0.25em;
}
/* ============================== */
/* KaTeX Math Rendering Styles */
/* ============================== */
.katex {
font: normal 1.21em "KaTeX_Main", "Times New Roman", Times, serif;
line-height: 1.2;
text-rendering: auto;
}
.katex * {
-ms-high-contrast-adjust: none !important;
border-color: currentColor;
}
.katex .katex-mathml {
clip: rect(1px, 1px, 1px, 1px);
border: 0;
height: 1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
.katex .katex-html > .newline {
display: block;
}
.katex .base {
position: relative;
white-space: nowrap;
width: -webkit-min-content;
width: -moz-min-content;
width: min-content;
}
.katex .base,
.katex .strut {
display: inline-block;
}
.katex .textbf {
font-weight: 700;
}
.katex .textit {
font-style: italic;
}
.katex .textrm {
font-family: KaTeX_Main;
}
.katex .texttt {
font-family: KaTeX_Typewriter;
}
.katex .mathnormal {
font-family: "KaTeX_Math", "Times New Roman", Times, serif;
font-style: italic;
}
.katex .mathit {
font-family: "KaTeX_Main", "Times New Roman", Times, serif;
font-style: italic;
}
.katex .mathrm {
font-style: normal;
}
.katex .mathbf {
font-family: "KaTeX_Main", "Times New Roman", Times, serif;
font-weight: 700;
}
.katex .boldsymbol {
font-family: KaTeX_Math;
font-style: italic;
font-weight: 700;
}
.katex .amsrm,
.katex .mathbb,
.katex .textbb {
font-family: "KaTeX_AMS", "Times New Roman", Times, serif;
}
.katex .mathcal {
font-family: "KaTeX_Caligraphic", "Lucida Calligraphy", "Brush Script MT", cursive;
}
.katex .mathfrak,
.katex .textfrak {
font-family: "KaTeX_Fraktur", "Lucida Blackletter", fantasy;
}
.katex .mathboldfrak,
.katex .textboldfrak {
font-family: KaTeX_Fraktur;
font-weight: 700;
}
.katex .mathtt {
font-family: "KaTeX_Typewriter", "Courier New", Courier, monospace;
}
.katex .mathscr,
.katex .textscr {
font-family: "KaTeX_Script", "Lucida Handwriting", "Segoe Script", cursive;
}
.katex .mathsf,
.katex .textsf {
font-family: "KaTeX_SansSerif", Arial, Helvetica, sans-serif;
}
.katex .mathboldsf,
.katex .textboldsf {
font-family: KaTeX_SansSerif;
font-weight: 700;
}
.katex .mathitsf,
.katex .mathsfit,
.katex .textitsf {
font-family: KaTeX_SansSerif;
font-style: italic;
}
.katex .mainrm {
font-family: KaTeX_Main;
font-style: normal;
}
/* KaTeX Layout */
.katex .vlist-t {
border-collapse: collapse;
display: inline-table;
table-layout: fixed;
}
.katex .vlist-r {
display: table-row;
}
.katex .vlist {
display: table-cell;
position: relative;
vertical-align: bottom;
}
.katex .vlist > span {
display: block;
height: 0;
position: relative;
}
.katex .vlist > span > span {
display: inline-block;
}
.katex .vlist > span > .pstrut {
overflow: hidden;
width: 0;
}
.katex .vlist-t2 {
margin-right: -2px;
}
.katex .vlist-s {
display: table-cell;
font-size: 1px;
min-width: 2px;
vertical-align: bottom;
width: 2px;
}
.katex .vbox {
align-items: baseline;
display: inline-flex;
flex-direction: column;
}
.katex .hbox {
width: 100%;
display: inline-flex;
flex-direction: row;
}
.katex .thinbox {
display: inline-flex;
flex-direction: row;
max-width: 0;
width: 0;
}
/* KaTeX Fractions and Lines */
.katex .msupsub {
text-align: left;
}
.katex .mfrac > span > span {
text-align: center;
}
.katex .mfrac .frac-line {
border-bottom-style: solid;
display: inline-block;
width: 100%;
}
.katex .hdashline,
.katex .hline,
.katex .mfrac .frac-line,
.katex .overline .overline-line,
.katex .rule,
.katex .underline .underline-line {
min-height: 1px;
}
.katex .mspace {
display: inline-block;
}
/* KaTeX Positioning */
.katex .clap,
.katex .llap,
.katex .rlap {
position: relative;
width: 0;
}
.katex .clap > .inner,
.katex .llap > .inner,
.katex .rlap > .inner {
position: absolute;
}
.katex .clap > .fix,
.katex .llap > .fix,
.katex .rlap > .fix {
display: inline-block;
}
.katex .llap > .inner {
right: 0;
}
.katex .clap > .inner,
.katex .rlap > .inner {
left: 0;
}
.katex .clap > .inner > span {
margin-left: -50%;
margin-right: 50%;
}
/* KaTeX Rules and Lines */
.katex .rule {
border: 0 solid;
display: inline-block;
position: relative;
}
.katex .hline,
.katex .overline .overline-line,
.katex .underline .underline-line {
border-bottom-style: solid;
display: inline-block;
width: 100%;
}
.katex .hdashline {
border-bottom-style: dashed;
display: inline-block;
width: 100%;
}
/* KaTeX Square Root */
.katex .sqrt > .root {
margin-left: 0.27777778em;
margin-right: -0.55555556em;
}
/* KaTeX Sizing - Essential subset */
.katex .fontsize-ensurer.reset-size1.size1,
.katex .sizing.reset-size1.size1 {
font-size: 1em;
}
.katex .fontsize-ensurer.reset-size1.size2,
.katex .sizing.reset-size1.size2 {
font-size: 1.2em;
}
.katex .fontsize-ensurer.reset-size1.size3,
.katex .sizing.reset-size1.size3 {
font-size: 1.4em;
}
.katex .fontsize-ensurer.reset-size1.size4,
.katex .sizing.reset-size1.size4 {
font-size: 1.6em;
}
.katex .fontsize-ensurer.reset-size1.size5,
.katex .sizing.reset-size1.size5 {
font-size: 1.8em;
}
.katex .fontsize-ensurer.reset-size1.size6,
.katex .sizing.reset-size1.size6 {
font-size: 2em;
}
.katex .fontsize-ensurer.reset-size1.size7,
.katex .sizing.reset-size1.size7 {
font-size: 2.4em;
}
/* KaTeX Delimiters */
.katex .delimsizing.size1 {
font-family: KaTeX_Size1;
}
.katex .delimsizing.size2 {
font-family: KaTeX_Size2;
}
.katex .delimsizing.size3 {
font-family: KaTeX_Size3;
}
.katex .delimsizing.size4 {
font-family: KaTeX_Size4;
}
.katex .nulldelimiter {
display: inline-block;
width: 0.12em;
}
.katex .delimcenter,
.katex .op-symbol {
position: relative;
}
.katex .op-symbol.small-op {
font-family: KaTeX_Size1;
}
.katex .op-symbol.large-op {
font-family: KaTeX_Size2;
}
/* KaTeX Accents and Symbols */
.katex .accent > .vlist-t,
.katex .op-limits > .vlist-t {
text-align: center;
}
.katex .accent .accent-body {
position: relative;
}
.katex .accent .accent-body:not(.accent-full) {
width: 0;
}
.katex .overlay {
display: block;
}
/* KaTeX Tables */
.katex .mtable .vertical-separator {
display: inline-block;
min-width: 1px;
}
.katex .mtable .arraycolsep {
display: inline-block;
}
.katex .mtable .col-align-c > .vlist-t {
text-align: center;
}
.katex .mtable .col-align-l > .vlist-t {
text-align: left;
}
.katex .mtable .col-align-r > .vlist-t {
text-align: right;
}
/* KaTeX SVG Support */
.katex .svg-align {
text-align: left;
}
.katex svg {
fill: currentColor;
stroke: currentColor;
fill-rule: nonzero;
fill-opacity: 1;
stroke-width: 1;
stroke-linecap: butt;
stroke-linejoin: miter;
stroke-miterlimit: 4;
stroke-dasharray: none;
stroke-dashoffset: 0;
stroke-opacity: 1;
display: block;
height: inherit;
position: absolute;
width: 100%;
}
.katex svg path {
stroke: none;
}
.katex img {
border-style: none;
max-height: none;
max-width: none;
min-height: 0;
min-width: 0;
}
/* KaTeX Stretchy Elements */
.katex .stretchy {
display: block;
overflow: hidden;
position: relative;
width: 100%;
}
.katex .stretchy::after,
.katex .stretchy::before {
content: "";
}
.katex .hide-tail {
overflow: hidden;
position: relative;
width: 100%;
}
/* KaTeX Arrows and Braces */
.katex .halfarrow-left {
left: 0;
overflow: hidden;
position: absolute;
width: 50.2%;
}
.katex .halfarrow-right {
overflow: hidden;
position: absolute;
right: 0;
width: 50.2%;
}
.katex .brace-left {
left: 0;
overflow: hidden;
position: absolute;
width: 25.1%;
}
.katex .brace-center {
left: 25%;
overflow: hidden;
position: absolute;
width: 50%;
}
.katex .brace-right {
overflow: hidden;
position: absolute;
right: 0;
width: 25.1%;
}
/* KaTeX Padding and Boxes */
.katex .x-arrow-pad {
padding: 0 0.5em;
}
.katex .cd-arrow-pad {
padding: 0 0.55556em 0 0.27778em;
}
.katex .mover,
.katex .munder,
.katex .x-arrow {
text-align: center;
}
.katex .boxpad {
padding: 0 0.3em;
}
.katex .fbox,
.katex .fcolorbox {
border: 0.04em solid;
box-sizing: border-box;
}
/* KaTeX Cancel */
.katex .cancel-pad {
padding: 0 0.2em;
}
.katex .cancel-lap {
margin-left: -0.2em;
margin-right: -0.2em;
}
.katex .sout {
border-bottom-style: solid;
border-bottom-width: 0.08em;
}
/* KaTeX Angles */
.katex .angl {
border-right: 0.049em solid;
border-top: 0.049em solid;
box-sizing: border-box;
margin-right: 0.03889em;
}
.katex .anglpad {
padding: 0 0.03889em;
}
/* KaTeX Equation Numbers */
.katex .eqn-num::before {
content: "(" counter(katexEqnNo) ")";
counter-increment: katexEqnNo;
}
.katex .mml-eqn-num::before {
content: "(" counter(mmlEqnNo) ")";
counter-increment: mmlEqnNo;
}
/* KaTeX Display Mode */
.katex-display {
display: block;
margin: 1em 0;
text-align: center;
}
.katex-display > .katex {
display: block;
text-align: center;
white-space: nowrap;
}
.katex-display > .katex > .katex-html {
display: block;
position: relative;
}
.katex-display > .katex > .katex-html > .tag {
position: absolute;
right: 0;
}
.katex-display.leqno > .katex > .katex-html > .tag {
left: 0;
right: auto;
}
.katex-display.fleqn > .katex {
padding-left: 2em;
text-align: left;
}
/* ============================== */
@ -1260,37 +559,6 @@ a[data-footnote-backref] {
margin-bottom: 0.5em;
}
/* ============================== */
/* Emoji Support */
/* ============================== */
.message-bubble.assistant .emoji {
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-weight: normal;
line-height: 1;
}
/* ============================== */
/* Line Numbers for Code Blocks (optional) */
/* ============================== */
/* If using line numbers with rehype-highlight */
.hljs .code-line {
display: block;
padding-left: 1em;
margin-left: -1em;
}
.hljs .numbered-code-line::before {
content: attr(data-line-number);
display: inline-block;
width: 2em;
text-align: right;
margin-right: 1em;
color: var(--text-faint);
user-select: none;
}
/* ============================== */
/* Responsive adjustments */
/* ============================== */
@ -1330,9 +598,4 @@ a[data-footnote-backref] {
background-color: #f6f8fa;
color: #000000;
}
.hljs {
background: #ffffff;
color: #000000;
}
}

View file

@ -2,570 +2,152 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { StreamingMarkdownService } from '../../Services/StreamingMarkdownService';
import * as DependencyService from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import type { HTMLService } from '../../Services/HTMLService';
import type { VaultCacheService } from '../../Services/VaultCacheService';
import { Exception } from '../../Helpers/Exception';
const mockPlugin = { app: {} };
vi.mock('obsidian', async (importOriginal) => {
const original = await importOriginal<typeof import('obsidian')>();
return {
...original,
MarkdownRenderer: {
render: vi.fn(async (_app: unknown, markdown: string, container: HTMLElement) => {
const blocks = markdown.split(/\n{2,}/).filter(b => b.length > 0);
container.innerHTML = blocks.map(block => {
if (block.startsWith('# ')) return `<h1>${block.slice(2)}</h1>`;
if (block.startsWith('```')) {
const lines = block.split('\n');
const code = lines.slice(1, lines[lines.length - 1] === '```' ? -1 : undefined).join('\n');
return `<pre><code>${code}</code></pre>`;
}
return `<p>${block}</p>`;
}).join('');
})
}
};
});
describe('StreamingMarkdownService', () => {
let service: StreamingMarkdownService;
let mockHTMLService: Partial<HTMLService>;
let mockVaultCacheService: Partial<VaultCacheService>;
beforeEach(() => {
// Mock VaultCacheService to provide wikiLinks
mockVaultCacheService = {
wikiLinks: {
links: ['file1', 'file2', 'folder/file3']
}
} as any;
// Mock HTMLService
mockHTMLService = {
clearElement: vi.fn((element: HTMLElement) => {
while (element.firstChild) {
element.removeChild(element.firstChild);
}
}),
setHTMLContent: vi.fn((container: HTMLElement, htmlString: string) => {
// Clear the container
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Parse and append HTML
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
while (doc.body.firstChild) {
container.appendChild(doc.body.firstChild);
}
}),
parseHTMLString: vi.fn(),
parseHTMLToContainer: vi.fn()
};
// Mock DependencyService.Resolve to return our mocks
vi.spyOn(DependencyService, 'Resolve').mockImplementation((serviceId: symbol) => {
if (serviceId === Services.VaultCacheService) {
return mockVaultCacheService as VaultCacheService;
}
if (serviceId === Services.HTMLService) {
return mockHTMLService as HTMLService;
}
throw new Error(`Unexpected service request: ${serviceId.toString()}`);
});
// Mock Exception.log
vi.spyOn(Exception, 'log').mockImplementation(() => {});
service = new StreamingMarkdownService();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('formatText', () => {
it('should convert basic markdown to HTML', () => {
const markdown = '# Hello World\n\nThis is a test.';
const result = service.formatText(markdown);
expect(result).toContain('<h1>');
expect(result).toContain('Hello World');
expect(result).toContain('<p>');
expect(result).toContain('This is a test.');
});
it('should handle bold text', () => {
const markdown = '**bold text**';
const result = service.formatText(markdown);
expect(result).toContain('<strong>');
expect(result).toContain('bold text');
});
it('should handle italic text', () => {
const markdown = '*italic text*';
const result = service.formatText(markdown);
expect(result).toContain('<em>');
expect(result).toContain('italic text');
});
it('should handle code blocks', () => {
const markdown = '```javascript\nconst x = 1;\n```';
const result = service.formatText(markdown);
expect(result).toContain('<code');
// rehype-highlight adds span tags for syntax highlighting
expect(result).toContain('const');
expect(result).toContain('x');
});
it('should handle inline code', () => {
const markdown = 'Use `console.log()` to debug.';
const result = service.formatText(markdown);
expect(result).toContain('<code>');
expect(result).toContain('console.log()');
});
it('should handle lists', () => {
const markdown = '- Item 1\n- Item 2\n- Item 3';
const result = service.formatText(markdown);
expect(result).toContain('<ul>');
expect(result).toContain('<li>');
expect(result).toContain('Item 1');
expect(result).toContain('Item 2');
});
it('should handle numbered lists', () => {
const markdown = '1. First\n2. Second\n3. Third';
const result = service.formatText(markdown);
expect(result).toContain('<ol>');
expect(result).toContain('<li>');
expect(result).toContain('First');
});
it('should handle links', () => {
const markdown = '[Google](https://google.com)';
const result = service.formatText(markdown);
expect(result).toContain('<a');
expect(result).toContain('href="https://google.com"');
expect(result).toContain('Google');
});
it('should handle LaTeX math with double dollar signs', () => {
const markdown = '$$E = mc^2$$';
const result = service.formatText(markdown);
// rehype-katex converts LaTeX to HTML with math markup
expect(result).toContain('E');
expect(result).toContain('mc');
});
it('should handle inline LaTeX with parentheses notation', () => {
const markdown = 'The formula \\(x^2 + y^2 = z^2\\) is correct.';
const result = service.formatText(markdown);
expect(result).toContain('formula');
expect(result).toContain('correct');
});
it('should handle empty string', () => {
const result = service.formatText('');
expect(result).toBeDefined();
expect(typeof result).toBe('string');
});
it('should handle plain text without markdown', () => {
const text = 'Just plain text';
const result = service.formatText(text);
expect(result).toContain('Just plain text');
expect(result).toContain('<p>');
});
it('should return fallback HTML on processing error', () => {
// Mock processor to throw error
const originalProcessor = (service as any).processor;
(service as any).processor = {
processSync: vi.fn().mockImplementation(() => {
throw new Error('Processing failed');
})
};
const result = service.formatText('test');
// Should use fallback HTML generation
expect(result).toBeDefined();
expect(result).toContain('test');
// Restore processor
(service as any).processor = originalProcessor;
});
it('should handle special HTML characters in fallback', () => {
// Force fallback by making processor fail
const originalProcessor = (service as any).processor;
(service as any).processor = {
processSync: vi.fn().mockImplementation(() => {
throw new Error('Processing failed');
})
};
const result = service.formatText('<script>alert("xss")</script>');
// Should escape HTML in fallback
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;');
expect(result).toContain('&gt;');
// Restore processor
(service as any).processor = originalProcessor;
});
});
describe('preprocessContent', () => {
it('should normalize line endings', () => {
const content = 'Line 1\r\nLine 2\rLine 3\n';
const result = (service as any).preprocessContent(content);
// All line endings should be normalized to \n
expect(result).not.toContain('\r');
expect(result).toContain('Line 1\nLine 2\nLine 3');
});
it('should convert LaTeX bracket notation to dollar signs', () => {
const content = 'Formula: \\[x^2 + y^2 = z^2\\]';
const result = (service as any).preprocessContent(content);
expect(result).toContain('$$');
expect(result).toContain('x^2 + y^2 = z^2');
});
it('should convert LaTeX parentheses to inline math', () => {
const content = 'Inline \\(a + b = c\\) formula';
const result = (service as any).preprocessContent(content);
// Converts \(...\) to $...$ (single dollar, not double)
expect(result).toContain('$a + b = c$');
});
it('should add blank lines before headers', () => {
const content = 'Paragraph\n# Header';
const result = (service as any).preprocessContent(content);
expect(result).toContain('Paragraph\n\n# Header');
});
it('should not add blank lines before headers at start', () => {
const content = '# Header at start';
const result = (service as any).preprocessContent(content);
// Should not have extra blank line at the start
expect(result.startsWith('# Header')).toBe(true);
});
it('should collapse excessive newlines', () => {
const content = 'Line 1\n\n\n\n\nLine 2';
const result = (service as any).preprocessContent(content);
// Should reduce to max 3 newlines
expect(result).not.toContain('\n\n\n\n');
expect(result).toContain('Line 1');
expect(result).toContain('Line 2');
});
it('should normalize list formatting', () => {
const content = '- Item with extra spaces\n* Another item';
const result = (service as any).preprocessContent(content);
// Should normalize to single space after bullet
expect(result).toContain('- Item with extra spaces');
expect(result).toContain('* Another item');
});
it('should format task list checkboxes', () => {
const content = '- [x] Done\n- [ ]Not done';
const result = (service as any).preprocessContent(content);
// Should normalize task list format
expect(result).toContain('- [x]');
expect(result).toContain('- [ ]');
});
});
describe('getFallbackHTML', () => {
it('should escape HTML entities', () => {
const text = '<div>Test & "quotes"</div>';
const result = (service as any).getFallbackHTML(text);
expect(result).toContain('&lt;div&gt;');
expect(result).toContain('&amp;');
expect(result).toContain('&gt;');
});
it('should convert code blocks', () => {
const text = '```\ncode here\n```';
const result = (service as any).getFallbackHTML(text);
expect(result).toContain('<pre>');
expect(result).toContain('<code>');
expect(result).toContain('code here');
});
it('should handle unordered lists', () => {
const text = '* Item 1\n* Item 2';
const result = (service as any).getFallbackHTML(text);
expect(result).toContain('<ul>');
expect(result).toContain('<li>');
expect(result).toContain('Item 1');
});
it('should close unclosed code blocks', () => {
const text = '```\ncode\nmore code';
const result = (service as any).getFallbackHTML(text);
expect(result).toContain('</code></pre>');
});
it('should close unclosed lists', () => {
const text = '* Item 1\n* Item 2';
const result = (service as any).getFallbackHTML(text);
expect(result).toContain('</ul>');
});
it('should convert bold markdown', () => {
const text = '**bold text**';
const result = (service as any).getFallbackHTML(text);
expect(result).toContain('<strong>');
expect(result).toContain('bold text');
});
it('should convert italic markdown', () => {
const text = '*italic text*';
const result = (service as any).getFallbackHTML(text);
expect(result).toContain('<em>');
expect(result).toContain('italic text');
});
it('should convert inline code', () => {
const text = 'Use `code` here';
const result = (service as any).getFallbackHTML(text);
expect(result).toContain('<code>');
expect(result).toContain('code');
});
it('should wrap text in paragraphs', () => {
const text = 'Line 1\nLine 2';
const result = (service as any).getFallbackHTML(text);
expect(result).toContain('<p>');
});
it('should not wrap empty lines in paragraphs', () => {
const text = 'Line 1\n\nLine 2';
const result = (service as any).getFallbackHTML(text);
const paragraphCount = (result.match(/<p>/g) || []).length;
expect(paragraphCount).toBe(2); // One for each non-empty line
});
});
describe('streaming - initializeStream', () => {
it('should create streaming state for message', () => {
const container = document.createElement('div');
container.innerHTML = '<p>Old content</p>';
service.initializeStream('msg-1', container);
expect(container.innerHTML).toBe('');
// Verify state exists internally
const state = (service as any).streamingStates.get('msg-1');
expect(state).toBeDefined();
expect(state.buffer).toBe('');
expect(state.isComplete).toBe(false);
});
it('should clear existing content in container', () => {
const container = document.createElement('div');
container.innerHTML = '<p>Existing content</p>';
service.initializeStream('msg-1', container);
expect(container.innerHTML).toBe('');
});
it('should handle multiple stream initializations', () => {
const container1 = document.createElement('div');
const container2 = document.createElement('div');
service.initializeStream('msg-1', container1);
service.initializeStream('msg-2', container2);
const states = (service as any).streamingStates;
expect(states.get('msg-1')).toBeDefined();
expect(states.get('msg-2')).toBeDefined();
});
});
describe('streaming - streamChunk', () => {
it('should update buffer with new content', () => {
const container = document.createElement('div');
service.initializeStream('msg-1', container);
service.streamChunk('msg-1', '# Title');
const state = (service as any).streamingStates.get('msg-1');
expect(state.buffer).toBe('# Title');
});
it('should render content after debounce', async () => {
const container = document.createElement('div');
service.initializeStream('msg-1', container);
service.streamChunk('msg-1', '**bold**');
// Wait for debounce (50ms + buffer)
await new Promise(resolve => setTimeout(resolve, 100));
expect(container.innerHTML).toContain('bold');
});
it('should handle multiple chunks for same message', () => {
const container = document.createElement('div');
service.initializeStream('msg-1', container);
service.streamChunk('msg-1', 'Part 1');
service.streamChunk('msg-1', 'Part 1\nPart 2');
service.streamChunk('msg-1', 'Part 1\nPart 2\nPart 3');
const state = (service as any).streamingStates.get('msg-1');
expect(state.buffer).toBe('Part 1\nPart 2\nPart 3');
});
it('should do nothing if message not initialized', () => {
expect(() => {
service.streamChunk('non-existent', 'test');
}).not.toThrow();
});
it('should do nothing if stream already complete', () => {
const container = document.createElement('div');
service.initializeStream('msg-1', container);
const state = (service as any).streamingStates.get('msg-1');
state.isComplete = true;
service.streamChunk('msg-1', 'new content');
// Buffer should not update
expect(state.buffer).toBe('');
});
});
describe('streaming - finalizeStream', () => {
it('should render final content immediately', () => {
const container = document.createElement('div');
service.initializeStream('msg-1', container);
service.finalizeStream('msg-1', '# Final Content');
expect(container.innerHTML).toContain('Final Content');
});
it('should mark stream as complete', () => {
const container = document.createElement('div');
service.initializeStream('msg-1', container);
service.finalizeStream('msg-1', 'Done');
const state = (service as any).streamingStates.get('msg-1');
// State should be deleted after finalization
expect(state).toBeUndefined();
});
it('should cleanup streaming state', () => {
const container = document.createElement('div');
service.initializeStream('msg-1', container);
service.finalizeStream('msg-1', 'Done');
const states = (service as any).streamingStates;
expect(states.has('msg-1')).toBe(false);
});
it('should cleanup render timeout', () => {
const container = document.createElement('div');
service.initializeStream('msg-1', container);
service.streamChunk('msg-1', 'test');
service.finalizeStream('msg-1', 'Done');
const timeouts = (service as any).renderTimeouts;
expect(timeouts.has('msg-1')).toBe(false);
});
it('should do nothing if message not initialized', () => {
expect(() => {
service.finalizeStream('non-existent', 'test');
}).not.toThrow();
});
it('should handle finalization with empty content', () => {
const container = document.createElement('div');
service.initializeStream('msg-1', container);
service.finalizeStream('msg-1', '');
expect(container.innerHTML).toBeDefined();
});
});
describe('streaming - debounce behavior', () => {
it('should debounce rapid updates', async () => {
const container = document.createElement('div');
service.initializeStream('msg-1', container);
// Send multiple rapid updates
service.streamChunk('msg-1', 'v1');
service.streamChunk('msg-1', 'v2');
service.streamChunk('msg-1', 'v3');
// Wait for debounce
await new Promise(resolve => setTimeout(resolve, 100));
// Should render final version
expect(container.innerHTML).toContain('v3');
});
it('should clear pending timeout on new chunk', () => {
const container = document.createElement('div');
service.initializeStream('msg-1', container);
service.streamChunk('msg-1', 'first');
const timeouts = (service as any).renderTimeouts;
const firstTimeout = timeouts.get('msg-1');
service.streamChunk('msg-1', 'second');
const secondTimeout = timeouts.get('msg-1');
// Should have different timeout IDs
expect(firstTimeout).not.toBe(secondTimeout);
});
});
describe('constructor', () => {
it('should initialize unified processor', () => {
const testService = new StreamingMarkdownService();
expect((testService as any).processor).toBeDefined();
expect((testService as any).processor).not.toBeNull();
});
it('should use VaultCacheService wikiLinks for permalink resolution', () => {
const testService = new StreamingMarkdownService();
// Verify the service was initialized with VaultCacheService
expect((testService as any).vaultCacheService).toBeDefined();
expect((testService as any).vaultCacheService.wikiLinks).toBeDefined();
expect((testService as any).vaultCacheService.wikiLinks.links).toEqual(['file1', 'file2', 'folder/file3']);
});
it('should initialize empty streaming states', () => {
const testService = new StreamingMarkdownService();
const states = (testService as any).streamingStates;
expect(states).toBeInstanceOf(Map);
expect(states.size).toBe(0);
});
});
let service: StreamingMarkdownService;
beforeEach(() => {
vi.spyOn(DependencyService, 'Resolve').mockImplementation((serviceId: symbol) => {
if (serviceId === Services.VaultkeeperAIPlugin) return mockPlugin as any;
throw new Error(`Unexpected service request: ${serviceId.toString()}`);
});
service = new StreamingMarkdownService();
});
afterEach(() => {
vi.restoreAllMocks();
});
// --- Existing contract (isFinal=true behaviour matches old render) ---
it('clears container and renders markdown when final', async () => {
const container = document.createElement('div');
container.innerHTML = '<span>old</span>';
await service.render('hello world', container, true);
expect(container.innerHTML).not.toContain('old');
expect(container.innerHTML).toContain('hello world');
});
it('replaces previous content on each final call', async () => {
const container = document.createElement('div');
await service.render('first', container, true);
await service.render('first\n\nsecond', container, true);
const paragraphs = container.querySelectorAll('p');
expect(paragraphs).toHaveLength(2);
expect(paragraphs[0].textContent).toBe('first');
expect(paragraphs[1].textContent).toBe('second');
});
// --- Incremental rendering ---
it('renders single incomplete block in live tail only', async () => {
const container = document.createElement('div');
// No \n\n yet — everything is live tail
await service.render('Hello world', container);
expect(container.textContent).toContain('Hello world');
});
it('freezes completed blocks and only re-renders live tail', async () => {
const { MarkdownRenderer } = await import('obsidian');
const renderSpy = vi.mocked(MarkdownRenderer.render);
const container = document.createElement('div');
// First chunk: one complete paragraph + live tail
await service.render('First paragraph\n\nLive tail', container);
const callsAfterFirst = renderSpy.mock.calls.length;
// Second chunk: same frozen block, live tail grows
await service.render('First paragraph\n\nLive tail continues', container);
const callsAfterSecond = renderSpy.mock.calls.length;
// The frozen block should NOT have been re-rendered (only live tail re-renders)
// So calls added == 1 (just the live tail), not 2
expect(callsAfterSecond - callsAfterFirst).toBe(1);
expect(container.textContent).toContain('First paragraph');
expect(container.textContent).toContain('Live tail continues');
});
it('does not split an open code fence into frozen content', async () => {
const container = document.createElement('div');
// The \n\n is inside an open code block — should NOT freeze anything
await service.render('```js\nconst x = 1;\n\nconst y = 2;', container);
// Split point should be 0 (nothing frozen), all in live tail
const children = Array.from(container.children);
const frozenDiv = children[0] as HTMLElement;
expect(frozenDiv.innerHTML).toBe('');
});
it('freezes content before a code block and keeps open fence in live tail', async () => {
const container = document.createElement('div');
const text = 'Intro paragraph\n\n```js\nconst x = 1;';
await service.render(text, container);
// "Intro paragraph" should be frozen, code block stays in live tail
const children = Array.from(container.children);
const frozenDiv = children[0] as HTMLElement;
const liveDiv = children[1] as HTMLElement;
expect(frozenDiv.textContent).toContain('Intro paragraph');
expect(liveDiv.textContent).toContain('const x = 1;');
});
it('freezes a complete code block once closing fence arrives', async () => {
const { MarkdownRenderer } = await import('obsidian');
const renderSpy = vi.mocked(MarkdownRenderer.render);
const container = document.createElement('div');
// Open fence — nothing frozen yet
await service.render('Before\n\n```js\ncode here', container);
const callsWithOpenFence = renderSpy.mock.calls.length;
// Close the fence with a following \n\n — now the whole block is freezable
await service.render('Before\n\n```js\ncode here\n```\n\nAfter content', container);
expect(container.textContent).toContain('Before');
expect(container.textContent).toContain('code here');
expect(container.textContent).toContain('After content');
// Render was called again (live tail updated)
expect(renderSpy.mock.calls.length).toBeGreaterThan(callsWithOpenFence);
});
it('isFinal=true resets state and does a clean full render', async () => {
const container = document.createElement('div');
// Simulate streaming
await service.render('Paragraph one\n\nLive', container);
// Finalize
await service.render('Paragraph one\n\nLive tail complete', container, true);
// After finalize, container should have clean output (no frozen/live divs)
const paragraphs = container.querySelectorAll('p');
expect(paragraphs.length).toBeGreaterThanOrEqual(1);
expect(container.textContent).toContain('Paragraph one');
expect(container.textContent).toContain('Live tail complete');
});
});

2071
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -20,55 +20,45 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.1",
"@eslint/js": "^10.0.1",
"@testing-library/svelte": "^5.3.1",
"@types/express": "^5.0.6",
"@types/node": "^25.8.0",
"@types/node": "^25.9.1",
"@types/path-browserify": "^1.0.3",
"@typescript-eslint/eslint-plugin": "8.59.3",
"@typescript-eslint/parser": "8.59.3",
"@vitest/ui": "^4.1.6",
"esbuild": "^0.28.0",
"@typescript-eslint/eslint-plugin": "8.59.4",
"@typescript-eslint/parser": "8.59.4",
"@vitest/ui": "^4.1.7",
"esbuild": "^0.28.0",
"esbuild-svelte": "^0.9.5",
"eslint": "^10.4.0",
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.1",
"eslint-plugin-obsidianmd": "^0.3.0",
"globals": "^14.0.0",
"globals": "^17.6.0",
"happy-dom": "^20.9.0",
"obsidian": "latest",
"svelte": "^5.55.7",
"svelte": "^5.55.9",
"svelte-check": "^4.4.8",
"svelte-preprocess": "^6.0.3",
"svelte-preprocess": "^6.0.5",
"tslib": "2.8.1",
"typescript": "^6.0.3",
"vitest": "^4.1.6"
"vitest": "^4.1.7"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.96.0",
"@google/genai": "^2.3.0",
"@shikijs/rehype": "^4.0.2",
"@anthropic-ai/sdk": "^0.98.0",
"@google/genai": "^2.6.0",
"@shikijs/rehype": "^4.1.0",
"core-js": "^3.49.0",
"diff": "^9.0.0",
"diff2html": "^3.4.56",
"express": "^5.2.1",
"fuzzysort": "^3.1.0",
"highlight.js": "^11.11.1",
"katex": "^0.16.47",
"katex": "^0.17.0",
"lowlight": "^3.3.0",
"officeparser": "^7.0.3",
"openai": "^6.38.0",
"openai": "^6.39.0",
"path-browserify": "^1.0.1",
"regex-parser": "^2.3.1",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"rehype-stringify": "^10.0.1",
"remark-emoji": "^5.0.2",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"remark-wiki-link": "^2.0.1",
"unified": "^11.0.5",
"unpdf": "^1.6.2",
"uuid": "^14.0.0",
"zod": "^4.4.3"