chore: update Angular TypeScript build info cache
This commit is contained in:
@@ -448,7 +448,7 @@
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative w-full max-w-2xl lg:max-w-3xl">
|
||||
<div class="relative w-full lg:flex-1 lg:max-w-none lg:min-w-0">
|
||||
<svg class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-obs-l-text-muted dark:text-obs-d-text-muted" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -370,7 +370,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center gap-3">
|
||||
<div class="relative w-full max-w-2xl lg:max-w-3xl">
|
||||
<div class="relative w-full flex-1 min-w-0">
|
||||
<svg class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-obs-l-text-muted dark:text-obs-d-text-muted" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<input
|
||||
type="text"
|
||||
@@ -398,6 +398,7 @@
|
||||
[noteHtmlContent]="renderedNoteContent()"
|
||||
(noteLinkClicked)="selectNote($event)"
|
||||
(tagClicked)="handleTagClick($event)"
|
||||
(wikiLinkActivated)="handleWikiLink($event)"
|
||||
></app-note-viewer>
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center">
|
||||
|
||||
+129
-36
@@ -1,14 +1,13 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, ElementRef, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
// Services
|
||||
import { VaultService } from './services/vault.service';
|
||||
import { MarkdownService } from './services/markdown.service';
|
||||
|
||||
// Components
|
||||
import { FileExplorerComponent } from './components/file-explorer/file-explorer.component';
|
||||
import { NoteViewerComponent } from './components/note-viewer/note-viewer.component';
|
||||
import { NoteViewerComponent, WikiLinkActivation } from './components/note-viewer/note-viewer.component';
|
||||
import { GraphViewComponent } from './components/graph-view/graph-view.component';
|
||||
import { TagsViewComponent } from './components/tags-view/tags-view.component';
|
||||
import { MarkdownCalendarComponent } from './components/markdown-calendar/markdown-calendar.component';
|
||||
@@ -78,6 +77,7 @@ export class AppComponent implements OnDestroy {
|
||||
this.calendarResults().length > 0
|
||||
);
|
||||
private calendarSearchTriggered = false;
|
||||
private pendingWikiNavigation = signal<{ noteId: string; heading?: string; block?: string } | null>(null);
|
||||
|
||||
// --- Data Signals ---
|
||||
fileTree = this.vaultService.fileTree;
|
||||
@@ -203,6 +203,24 @@ export class AppComponent implements OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const pending = this.pendingWikiNavigation();
|
||||
const activeNoteId = this.selectedNoteId();
|
||||
const html = this.renderedNoteContent();
|
||||
if (!pending || pending.noteId !== activeNoteId || !html) {
|
||||
return;
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (pending.heading) {
|
||||
this.scrollToHeading(pending.heading);
|
||||
} else if (pending.block) {
|
||||
this.scrollToBlock(pending.block);
|
||||
}
|
||||
this.pendingWikiNavigation.set(null);
|
||||
});
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
@@ -395,27 +413,6 @@ export class AppComponent implements OnDestroy {
|
||||
this.isSidebarOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
handleTagClick(tagName: string): void {
|
||||
const normalized = tagName.replace(/^#/, '').trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
this.setView('search');
|
||||
this.sidebarSearchTerm.set(`#${normalized}`);
|
||||
}
|
||||
|
||||
clearTagFilter(): void {
|
||||
this.sidebarSearchTerm.set('');
|
||||
}
|
||||
|
||||
clearCalendarResults(): void {
|
||||
this.calendarResults.set([]);
|
||||
this.calendarSearchState.set('idle');
|
||||
this.calendarSearchError.set(null);
|
||||
this.calendarSelectionLabel.set(null);
|
||||
this.calendarSearchTriggered = false;
|
||||
}
|
||||
|
||||
updateSearchTerm(term: string, focusSearch = false): void {
|
||||
this.sidebarSearchTerm.set(term ?? '');
|
||||
@@ -424,32 +421,128 @@ export class AppComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
handleWikiLink(link: WikiLinkActivation): void {
|
||||
const target = link.target?.trim();
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const note = this.resolveWikiTarget(target);
|
||||
if (!note) {
|
||||
console.warn('[ObsiViewer] Wiki link target not found:', link);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingWikiNavigation.set({
|
||||
noteId: note.id,
|
||||
heading: link.heading,
|
||||
block: link.block
|
||||
});
|
||||
this.selectNote(note.id);
|
||||
}
|
||||
|
||||
private generateToc(html: string): void {
|
||||
const toc: TocEntry[] = [];
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
|
||||
|
||||
headings.forEach(heading => {
|
||||
if (heading.id && heading.textContent) {
|
||||
toc.push({
|
||||
level: parseInt(heading.tagName.substring(1), 10),
|
||||
text: heading.textContent,
|
||||
id: heading.id
|
||||
});
|
||||
if (!heading.id || !heading.textContent) {
|
||||
return;
|
||||
}
|
||||
toc.push({
|
||||
level: parseInt(heading.tagName.substring(1), 10),
|
||||
text: heading.textContent,
|
||||
id: heading.id
|
||||
});
|
||||
});
|
||||
|
||||
this.tableOfContents.set(toc);
|
||||
}
|
||||
|
||||
scrollToHeading(id: string): void {
|
||||
// The note viewer component's content area is what scrolls
|
||||
|
||||
private scrollToHeading(id: string): void {
|
||||
const contentArea = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
|
||||
if(contentArea) {
|
||||
const element = contentArea.querySelector(`#${id}`);
|
||||
if (!contentArea) {
|
||||
return;
|
||||
}
|
||||
const element = contentArea.querySelector(`#${id}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
|
||||
private scrollToBlock(blockId: string | undefined): void {
|
||||
if (!blockId) {
|
||||
return;
|
||||
}
|
||||
const contentArea = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
|
||||
if (!contentArea) {
|
||||
return;
|
||||
}
|
||||
const selectors = [`[data-block-id="${blockId}"]`, `#${blockId}`];
|
||||
for (const selector of selectors) {
|
||||
const element = contentArea.querySelector(selector);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveWikiTarget(rawTarget: string): Note | undefined {
|
||||
const normalized = rawTarget.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lower = normalized.toLowerCase();
|
||||
const slug = this.slugifyForWiki(normalized);
|
||||
const normalizedPath = this.normalizePath(normalized);
|
||||
const notes = this.vaultService.allNotes();
|
||||
|
||||
return notes.find(note => {
|
||||
const title = note.title?.trim() ?? '';
|
||||
const titleLower = title.toLowerCase();
|
||||
const titleSlug = this.slugifyForWiki(title);
|
||||
const fileBase = note.fileName.replace(/\.md$/i, '').trim();
|
||||
const fileLower = fileBase.toLowerCase();
|
||||
const filePathNormalized = this.normalizePath(fileBase);
|
||||
const originalPath = this.normalizePath(note.originalPath);
|
||||
const aliasMatch = Array.isArray(note.frontmatter?.aliases) && (note.frontmatter.aliases as string[]).some(alias => {
|
||||
const trimmed = alias.trim();
|
||||
const aliasLower = trimmed.toLowerCase();
|
||||
return aliasLower === lower || this.slugifyForWiki(trimmed) === slug;
|
||||
});
|
||||
return (
|
||||
note.id === lower ||
|
||||
note.id === slug ||
|
||||
titleLower === lower ||
|
||||
titleSlug === slug ||
|
||||
fileLower === lower ||
|
||||
filePathNormalized === normalizedPath ||
|
||||
originalPath === normalizedPath ||
|
||||
aliasMatch
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private slugifyForWiki(value: string): string {
|
||||
return value
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
private normalizePath(path: string): string {
|
||||
return path
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\.md$/i, '')
|
||||
.replace(/^\/+/, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,8 +6,27 @@ import mermaid, { MermaidConfig, RenderResult } from 'mermaid';
|
||||
|
||||
type MermaidLib = typeof mermaid;
|
||||
|
||||
type MathJaxInstance = {
|
||||
tex2chtml(math: string, options: { display: boolean }): HTMLElement;
|
||||
startup: {
|
||||
promise: Promise<void>;
|
||||
document: {
|
||||
clear(): void;
|
||||
updateDocument(): void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type MetadataEntryType = 'text' | 'date' | 'email' | 'url' | 'number' | 'boolean' | 'image' | 'list' | 'object';
|
||||
|
||||
export interface WikiLinkActivation {
|
||||
target: string;
|
||||
heading?: string;
|
||||
headingText?: string;
|
||||
block?: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
interface MetadataEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -129,6 +148,7 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
note = input.required<Note>();
|
||||
noteHtmlContent = input.required<string>();
|
||||
noteLinkClicked = output<string>();
|
||||
wikiLinkActivated = output<WikiLinkActivation>();
|
||||
tagClicked = output<string>();
|
||||
|
||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||
@@ -140,6 +160,8 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
private mermaidRenderScheduled = false;
|
||||
private mermaidLoader: Promise<MermaidLib> | null = null;
|
||||
private mermaidLib: MermaidLib | null = null;
|
||||
private mathRenderScheduled = false;
|
||||
private mathJaxLoader: Promise<MathJaxInstance> | null = null;
|
||||
private readonly dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' });
|
||||
private readonly metadataKeysToExclude = new Set(['tags', 'tag', 'keywords']);
|
||||
private attachmentErrorCleanup: (() => void) | null = null;
|
||||
@@ -208,6 +230,7 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
this.noteHtmlContent();
|
||||
this.scheduleMermaidRender();
|
||||
this.scheduleAttachmentHandlers();
|
||||
this.scheduleMathRender();
|
||||
});
|
||||
|
||||
afterNextRender(() => {
|
||||
@@ -245,12 +268,31 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
} else if (noteTitle) {
|
||||
this.noteLinkClicked.emit(noteTitle.toLowerCase().replace(/\s+/g, '-'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const wikiAnchor = target.closest('a.md-wiki-link') as HTMLAnchorElement | null;
|
||||
if (wikiAnchor) {
|
||||
event.preventDefault();
|
||||
const targetValue = wikiAnchor.getAttribute('data-target') ?? '';
|
||||
const headingSlug = wikiAnchor.getAttribute('data-heading') ?? undefined;
|
||||
const headingText = wikiAnchor.getAttribute('data-heading-text') ?? undefined;
|
||||
const blockRef = wikiAnchor.getAttribute('data-block') ?? undefined;
|
||||
this.wikiLinkActivated.emit({
|
||||
target: targetValue,
|
||||
heading: headingSlug || undefined,
|
||||
headingText: headingText || undefined,
|
||||
block: blockRef || undefined,
|
||||
alias: wikiAnchor.textContent?.trim() ?? targetValue
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
this.setupMermaidObservation();
|
||||
this.scheduleMermaidRender();
|
||||
this.scheduleAttachmentHandlers();
|
||||
this.scheduleMathRender();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -259,10 +301,22 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
this.mermaidObserver = null;
|
||||
this.mermaidLib = null;
|
||||
this.mermaidLoader = null;
|
||||
this.mathJaxLoader = null;
|
||||
this.attachmentErrorCleanup?.();
|
||||
this.attachmentErrorCleanup = null;
|
||||
}
|
||||
|
||||
private scheduleMathRender(): void {
|
||||
if (this.mathRenderScheduled) {
|
||||
return;
|
||||
}
|
||||
this.mathRenderScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
this.mathRenderScheduled = false;
|
||||
this.renderMathExpressions();
|
||||
});
|
||||
}
|
||||
|
||||
getFrontmatterKeys(frontmatter: { [key: string]: any }): string[] {
|
||||
return Object.keys(frontmatter).filter(key => key !== 'tags' && key !== 'aliases' && key !== 'mtime');
|
||||
}
|
||||
@@ -449,6 +503,43 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private async renderMathExpressions(): Promise<void> {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const hostElement = this.elementRef.nativeElement as HTMLElement;
|
||||
const mathElements = Array.from(hostElement.querySelectorAll<HTMLElement>('.md-math-inline, .md-math-block'));
|
||||
const pending = mathElements.filter(element => element.dataset.math && element.dataset.mathProcessed !== 'true');
|
||||
if (!pending.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const mathJax = await this.ensureMathJax();
|
||||
for (const element of pending) {
|
||||
const expression = element.dataset.math ?? '';
|
||||
if (!expression.trim()) {
|
||||
continue;
|
||||
}
|
||||
const display = element.classList.contains('md-math-block');
|
||||
try {
|
||||
const rendered = mathJax.tex2chtml(expression, { display });
|
||||
element.innerHTML = '';
|
||||
element.appendChild(rendered);
|
||||
element.setAttribute('data-math-processed', 'true');
|
||||
} catch (error) {
|
||||
console.error('MathJax render error:', error);
|
||||
element.textContent = expression;
|
||||
}
|
||||
}
|
||||
mathJax.startup.document.clear();
|
||||
mathJax.startup.document.updateDocument();
|
||||
} catch (error) {
|
||||
console.error('Unable to initialise MathJax:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private decodeMermaidSource(encoded: string): string {
|
||||
try {
|
||||
return decodeURIComponent(encoded);
|
||||
@@ -464,6 +555,80 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
private ensureMathJax(): Promise<MathJaxInstance> {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.reject(new Error('MathJax requires a browser environment.'));
|
||||
}
|
||||
|
||||
const globalWithMathJax = window as unknown as {
|
||||
MathJax?: MathJaxInstance & { startup: { promise: Promise<void> } };
|
||||
_mathJaxLoading?: boolean;
|
||||
};
|
||||
|
||||
if (globalWithMathJax.MathJax) {
|
||||
return globalWithMathJax.MathJax.startup.promise.then(() => globalWithMathJax.MathJax as MathJaxInstance);
|
||||
}
|
||||
|
||||
if (this.mathJaxLoader) {
|
||||
return this.mathJaxLoader;
|
||||
}
|
||||
|
||||
this.mathJaxLoader = new Promise<MathJaxInstance>((resolve, reject) => {
|
||||
if (globalWithMathJax._mathJaxLoading) {
|
||||
const interval = window.setInterval(() => {
|
||||
if (globalWithMathJax.MathJax) {
|
||||
window.clearInterval(interval);
|
||||
globalWithMathJax.MathJax.startup.promise.then(() => resolve(globalWithMathJax.MathJax as MathJaxInstance));
|
||||
}
|
||||
}, 30);
|
||||
return;
|
||||
}
|
||||
|
||||
globalWithMathJax._mathJaxLoading = true;
|
||||
(window as any).MathJax = {
|
||||
startup: {
|
||||
typeset: false
|
||||
},
|
||||
tex: {
|
||||
inlineMath: [['$', '$'], ['\\(', '\\)']],
|
||||
displayMath: [['$$', '$$'], ['\\[', '\\]']],
|
||||
processEscapes: true
|
||||
},
|
||||
options: {
|
||||
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre']
|
||||
}
|
||||
};
|
||||
|
||||
const existing = document.getElementById('mathjax-loader') as HTMLScriptElement | null;
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => {
|
||||
if (globalWithMathJax.MathJax) {
|
||||
globalWithMathJax.MathJax.startup.promise.then(() => resolve(globalWithMathJax.MathJax as MathJaxInstance));
|
||||
}
|
||||
}, { once: true });
|
||||
existing.addEventListener('error', () => reject(new Error('Failed to load MathJax script.')), { once: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = 'mathjax-loader';
|
||||
script.async = true;
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js';
|
||||
script.addEventListener('load', () => {
|
||||
const mathJax = globalWithMathJax.MathJax;
|
||||
if (!mathJax) {
|
||||
reject(new Error('MathJax failed to initialise.'));
|
||||
return;
|
||||
}
|
||||
mathJax.startup.promise.then(() => resolve(mathJax as MathJaxInstance));
|
||||
}, { once: true });
|
||||
script.addEventListener('error', () => reject(new Error('Failed to load MathJax script.')), { once: true });
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return this.mathJaxLoader;
|
||||
}
|
||||
|
||||
private toggleMetadataPanel(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
const currentScroll = window.scrollY;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { MarkdownService } from './markdown.service.js';
|
||||
import type { Note } from '../types';
|
||||
|
||||
const service = new MarkdownService();
|
||||
const notes: Note[] = [];
|
||||
|
||||
const render = (markdown: string) => service.render(markdown, notes);
|
||||
|
||||
test('renders tables with inline markdown content', () => {
|
||||
const html = render(`| Syntax | Description |\n| --- | --- |\n| **bold** | \`code\` |\n`);
|
||||
|
||||
assert.ok(html.includes('<table'));
|
||||
assert.ok(html.includes('<strong>bold</strong>'));
|
||||
assert.ok(html.includes('<code class="inline-code">code</code>'));
|
||||
});
|
||||
|
||||
test('applies alignment markers for columns', () => {
|
||||
const html = render(`| Left | Center | Right |\n| :-- | :-: | --: |\n| one | two | three |\n`);
|
||||
|
||||
assert.ok(html.includes('style="text-align:left;"'));
|
||||
assert.ok(html.includes('style="text-align:center;"'));
|
||||
assert.ok(html.includes('style="text-align:right;"'));
|
||||
});
|
||||
|
||||
test('falls back gracefully when separator row is malformed', () => {
|
||||
const html = render(`| A | B |\n| -- | |\n| 1 | 2 |\n`);
|
||||
|
||||
assert.ok(!html.includes('<div class="markdown-table"><table>'));
|
||||
assert.ok(html.includes('| A | B |'));
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import MarkdownIt from 'markdown-it';
|
||||
import markdownItAnchor from 'markdown-it-anchor';
|
||||
import markdownItTaskLists from 'markdown-it-task-lists';
|
||||
import markdownItAttrs from 'markdown-it-attrs';
|
||||
import markdownItFootnote from 'markdown-it-footnote';
|
||||
import markdownItMultimdTable from 'markdown-it-multimd-table';
|
||||
import { Note } from '../types';
|
||||
|
||||
interface MarkdownRenderEnv {
|
||||
@@ -19,6 +21,27 @@ interface MarkdownItToken {
|
||||
attrJoin(name: string, value: string): void;
|
||||
}
|
||||
|
||||
interface WikiLinkPlaceholder {
|
||||
placeholder: string;
|
||||
alias: string;
|
||||
target: string;
|
||||
headingSlug?: string;
|
||||
headingText?: string;
|
||||
block?: string;
|
||||
}
|
||||
|
||||
interface MathPlaceholder {
|
||||
placeholder: string;
|
||||
expression: string;
|
||||
display: 'block' | 'inline';
|
||||
}
|
||||
|
||||
interface PreprocessResult {
|
||||
markdown: string;
|
||||
wikiLinks: WikiLinkPlaceholder[];
|
||||
math: MathPlaceholder[];
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@@ -29,11 +52,15 @@ export class MarkdownService {
|
||||
|
||||
render(markdown: string, allNotes: Note[], currentNote?: Note): string {
|
||||
const env: MarkdownRenderEnv = { codeBlockIndex: 0 };
|
||||
const markdownIt = this.createMarkdownIt(env);
|
||||
const headingSlugState = new Map<string, number>();
|
||||
const markdownIt = this.createMarkdownIt(env, headingSlugState);
|
||||
|
||||
const preprocessed = this.decorateInlineTags(markdown);
|
||||
let html = markdownIt.render(preprocessed, env);
|
||||
const preprocessing = this.preprocessMarkdown(markdown);
|
||||
const decorated = this.decorateInlineTags(preprocessing.markdown);
|
||||
let html = markdownIt.render(decorated, env);
|
||||
|
||||
html = this.restoreWikiLinks(html, preprocessing.wikiLinks);
|
||||
html = this.restoreMath(html, preprocessing.math);
|
||||
html = this.transformCallouts(html);
|
||||
html = this.transformTaskLists(html);
|
||||
html = this.wrapTables(html);
|
||||
@@ -62,10 +89,6 @@ export class MarkdownService {
|
||||
</figure>`;
|
||||
});
|
||||
|
||||
// Placeholder for unsupported elements
|
||||
html = html.replace(/\$\$(.*?)\$\$/g, '<div class="my-4 p-4 bg-obs-l-bg-secondary text-obs-l-text-muted dark:bg-obs-d-bg-secondary dark:text-obs-d-text-muted rounded text-center">[LaTeX Equation: $1]</div>');
|
||||
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -123,8 +146,8 @@ export class MarkdownService {
|
||||
this.tagColorCache.set(normalized, index);
|
||||
return `md-tag-color-${index}`;
|
||||
}
|
||||
private createMarkdownIt(env: MarkdownRenderEnv): MarkdownIt {
|
||||
const md = new MarkdownIt('commonmark', {
|
||||
private createMarkdownIt(env: MarkdownRenderEnv, slugState: Map<string, number>): MarkdownIt {
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: false,
|
||||
typographer: false,
|
||||
@@ -138,7 +161,7 @@ export class MarkdownService {
|
||||
});
|
||||
|
||||
md.use(markdownItAnchor, {
|
||||
slugify: (str) => this.slugify(str),
|
||||
slugify: (str) => this.slugify(str, slugState),
|
||||
tabIndex: false
|
||||
});
|
||||
|
||||
@@ -146,11 +169,26 @@ export class MarkdownService {
|
||||
enabled: false
|
||||
});
|
||||
|
||||
md.use(markdownItMultimdTable, {
|
||||
multiline: true,
|
||||
rowspan: true,
|
||||
headerless: true
|
||||
});
|
||||
|
||||
md.enable(['table']);
|
||||
|
||||
md.use(markdownItFootnote);
|
||||
|
||||
md.renderer.rules.fence = (tokens, idx, _options, renderEnv) => {
|
||||
const list = tokens as unknown as MarkdownItToken[];
|
||||
return this.renderFence(list[idx], renderEnv as MarkdownRenderEnv);
|
||||
};
|
||||
|
||||
md.renderer.rules.code_block = (tokens, idx, _options, renderEnv) => {
|
||||
const list = tokens as unknown as MarkdownItToken[];
|
||||
return this.renderFence(list[idx], renderEnv as MarkdownRenderEnv);
|
||||
};
|
||||
|
||||
md.renderer.rules.code_inline = (tokens, idx) => {
|
||||
const content = tokens[idx].content;
|
||||
return `<code class="inline-code">${this.escapeHtml(content)}</code>`;
|
||||
@@ -224,6 +262,22 @@ export class MarkdownService {
|
||||
</figure>`;
|
||||
};
|
||||
|
||||
md.renderer.rules.footnote_block_open = () => '<section class="md-footnotes text-sm text-obs-l-text-muted dark:text-obs-d-text-muted mt-12"><header class="font-semibold uppercase tracking-wide mb-2">Notes</header><ol class="space-y-2">';
|
||||
md.renderer.rules.footnote_block_close = () => '</ol></section>';
|
||||
|
||||
const defaultFootnoteOpen = md.renderer.rules.footnote_open ?? ((tokens, idx, options, renderEnv, self) => self.renderToken(tokens, idx, options));
|
||||
md.renderer.rules.footnote_open = (tokens, idx, options, renderEnv, self) => {
|
||||
tokens[idx].attrJoin('class', 'md-footnote-item');
|
||||
return defaultFootnoteOpen(tokens, idx, options, renderEnv, self);
|
||||
};
|
||||
|
||||
md.renderer.rules.footnote_ref = (tokens, idx, options, renderEnv, self) => {
|
||||
const list = tokens as unknown as MarkdownItToken[];
|
||||
const token = list[idx];
|
||||
const id = token.attrGet('id') ?? '';
|
||||
return `<sup class="md-footnote-ref"><a href="#${this.escapeHtml(id)}" class="md-external-link" rel="footnote">${token.content}</a></sup>`;
|
||||
};
|
||||
|
||||
md.renderer.rules.hr = () => '<hr class="my-6 border-obs-l-border dark:border-obs-d-border">';
|
||||
|
||||
return md;
|
||||
@@ -249,7 +303,7 @@ export class MarkdownService {
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
const highlightedCode = this.highlightCode(token.content, normalizedLanguage);
|
||||
const highlightedCode = this.highlightCode(token.content, normalizedLanguage, rawLanguage);
|
||||
bodyHtml = `<pre class="code-block__body"><code class="hljs" data-raw-code="${encodedRawCode}">${highlightedCode}</code></pre>`;
|
||||
}
|
||||
|
||||
@@ -269,13 +323,133 @@ export class MarkdownService {
|
||||
return `<div class="${wrapperClass}" style="max-width: 800px; width: 100%; margin: 0;" data-language="${languageLabel.toLowerCase()}" data-code-id="${codeId}">${headerHtml}${bodyHtml}</div>`;
|
||||
}
|
||||
|
||||
private highlightCode(code: string, language: string): string {
|
||||
if (language && hljs.getLanguage(language)) {
|
||||
return hljs.highlight(code, { language }).value;
|
||||
private highlightCode(code: string, normalizedLanguage: string, rawLanguage: string): string {
|
||||
if (normalizedLanguage && hljs.getLanguage(normalizedLanguage)) {
|
||||
return hljs.highlight(code, { language: normalizedLanguage }).value;
|
||||
}
|
||||
|
||||
if (rawLanguage && !hljs.getLanguage(normalizedLanguage)) {
|
||||
return this.escapeHtml(code);
|
||||
}
|
||||
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
|
||||
private preprocessMarkdown(input: string): PreprocessResult {
|
||||
const wikiLinks: WikiLinkPlaceholder[] = [];
|
||||
const math: MathPlaceholder[] = [];
|
||||
|
||||
let text = this.stripFrontmatter(input.replace(/\r\n/g, '\n'));
|
||||
|
||||
const wikiRegex = /(?<!\!)\[\[([^\]]+)\]\]/g;
|
||||
text = text.replace(wikiRegex, (_match, inner) => {
|
||||
const placeholder = `@@__WIKILINK_${wikiLinks.length}__@@`;
|
||||
const [targetPartRaw, aliasRaw] = `${inner}`.split('|');
|
||||
let targetPart = targetPartRaw ?? '';
|
||||
let alias = aliasRaw ?? '';
|
||||
|
||||
let block: string | undefined;
|
||||
let heading: string | undefined;
|
||||
|
||||
const blockIndex = targetPart.indexOf('^');
|
||||
if (blockIndex >= 0) {
|
||||
block = targetPart.slice(blockIndex + 1).trim();
|
||||
targetPart = targetPart.slice(0, blockIndex);
|
||||
}
|
||||
|
||||
const headingIndex = targetPart.indexOf('#');
|
||||
if (headingIndex >= 0) {
|
||||
heading = targetPart.slice(headingIndex + 1).trim();
|
||||
targetPart = targetPart.slice(0, headingIndex);
|
||||
}
|
||||
|
||||
const target = targetPart.trim();
|
||||
const display = (alias || heading || block || target).trim() || 'Untitled';
|
||||
const headingSlug = heading ? this.slugify(heading) : undefined;
|
||||
|
||||
wikiLinks.push({
|
||||
placeholder,
|
||||
alias: display,
|
||||
target,
|
||||
headingSlug,
|
||||
headingText: heading,
|
||||
block
|
||||
});
|
||||
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
const addMathPlaceholder = (expression: string, display: 'block' | 'inline') => {
|
||||
const placeholder = `@@__MATH_${display.toUpperCase()}_${math.length}__@@`;
|
||||
math.push({ placeholder, expression: expression.trim(), display });
|
||||
return placeholder;
|
||||
};
|
||||
|
||||
text = text.replace(/(^|[^\\])\$\$([\s\S]+?)\$\$/g, (match, prefix, expr) => {
|
||||
const placeholder = addMathPlaceholder(expr, 'block');
|
||||
return `${prefix}${placeholder}`;
|
||||
});
|
||||
|
||||
text = text.replace(/\\\[(.+?)\\\]/g, (_match, expr) => addMathPlaceholder(expr, 'block'));
|
||||
|
||||
text = text.replace(/(?<!\\)\$(?!\s)([^$]+?)(?<!\\)\$(?!\d)/g, (_match, expr) => addMathPlaceholder(expr, 'inline'));
|
||||
text = text.replace(/\\\((.+?)\\\)/g, (_match, expr) => addMathPlaceholder(expr, 'inline'));
|
||||
|
||||
return { markdown: text, wikiLinks, math };
|
||||
}
|
||||
|
||||
private restoreWikiLinks(html: string, links: WikiLinkPlaceholder[]): string {
|
||||
if (!links.length) {
|
||||
return html;
|
||||
}
|
||||
|
||||
return links.reduce((acc, link) => {
|
||||
const attrs: string[] = ['class="md-wiki-link"'];
|
||||
if (link.target) {
|
||||
attrs.push(`data-target="${this.escapeAttribute(link.target)}"`);
|
||||
}
|
||||
if (link.headingSlug) {
|
||||
attrs.push(`data-heading="${this.escapeAttribute(link.headingSlug)}"`);
|
||||
}
|
||||
if (link.headingText) {
|
||||
attrs.push(`data-heading-text="${this.escapeAttribute(link.headingText)}"`);
|
||||
}
|
||||
if (link.block) {
|
||||
attrs.push(`data-block="${this.escapeAttribute(link.block)}"`);
|
||||
}
|
||||
const replacement = `<a ${attrs.join(' ')}>${this.escapeHtml(link.alias)}</a>`;
|
||||
return acc.split(link.placeholder).join(replacement);
|
||||
}, html);
|
||||
}
|
||||
|
||||
private restoreMath(html: string, placeholders: MathPlaceholder[]): string {
|
||||
if (!placeholders.length) {
|
||||
return html;
|
||||
}
|
||||
|
||||
return placeholders.reduce((acc, item) => {
|
||||
const safeExpr = this.escapeHtml(item.expression);
|
||||
const dataAttr = this.escapeAttribute(item.expression);
|
||||
const replacement = item.display === 'block'
|
||||
? `<div class="md-math-block" data-math="${dataAttr}">${safeExpr}</div>`
|
||||
: `<span class="md-math-inline" data-math="${dataAttr}">${safeExpr}</span>`;
|
||||
return acc.split(item.placeholder).join(replacement);
|
||||
}, html);
|
||||
}
|
||||
|
||||
private stripFrontmatter(markdown: string): string {
|
||||
if (!markdown.startsWith('---')) {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
const match = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*(\n|$)/);
|
||||
if (!match) {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
return markdown.slice(match[0].length);
|
||||
}
|
||||
|
||||
private transformCallouts(html: string): string {
|
||||
return html.replace(/<blockquote([\s\S]*?)>([\s\S]*?)<\/blockquote>/g, (match, _attrs, inner) => {
|
||||
const firstParagraphMatch = inner.match(/^\s*<p>([\s\S]*?)<\/p>([\s\S]*)$/);
|
||||
@@ -377,12 +551,39 @@ export class MarkdownService {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
private slugify(text: string): string {
|
||||
return text.toString().toLowerCase()
|
||||
.replace(/\s+/g, '-') // Replace spaces with -
|
||||
.replace(/[^\w\-]+/g, '') // Remove all non-word chars
|
||||
.replace(/\-\-+/g, '-') // Replace multiple - with single -
|
||||
.replace(/^-+/, '') // Trim - from start of text
|
||||
.replace(/-+$/, ''); // Trim - from end of text
|
||||
private escapeAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
private slugify(text: string, state?: Map<string, number>): string {
|
||||
let base = text
|
||||
.toString()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.toLowerCase();
|
||||
|
||||
if (!base) {
|
||||
base = 'section';
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return base;
|
||||
}
|
||||
|
||||
let count = state.get(base);
|
||||
if (count === undefined) {
|
||||
count = 0;
|
||||
}
|
||||
state.set(base, count + 1);
|
||||
return count === 0 ? base : `${base}-${count}`;
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,76 @@
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.md-footnotes {
|
||||
border-top: 1px solid var(--footnotes-border, rgba(148, 163, 184, 0.4));
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.md-footnotes header {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--footnotes-heading, #475569);
|
||||
}
|
||||
|
||||
.md-footnotes ol {
|
||||
counter-reset: footnote-counter;
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.md-footnote-item {
|
||||
position: relative;
|
||||
color: var(--footnotes-text, #475569);
|
||||
}
|
||||
|
||||
.md-footnote-item p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.md-footnote-ref {
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
|
||||
.md-footnote-ref a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.md-footnote-ref a:hover,
|
||||
.md-footnote-ref a:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.md-wiki-link {
|
||||
color: var(--wiki-link, #4f46e5);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.md-wiki-link:hover,
|
||||
.md-wiki-link:focus-visible {
|
||||
color: var(--wiki-link-hover, #4338ca);
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.md-math-inline {
|
||||
font-family: "Cambria", "Times New Roman", serif;
|
||||
background-color: rgba(148, 163, 184, 0.2);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.md-math-block {
|
||||
font-family: "Cambria", "Times New Roman", serif;
|
||||
background-color: rgba(148, 163, 184, 0.15);
|
||||
padding: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
Reference in New Issue
Block a user