884 lines
		
	
	
		
			22 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
			
		
		
	
	
			884 lines
		
	
	
		
			22 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
# 💻 EXEMPLES DE CODE — Diffs Ciblés (5+ exemples copier-coller)
 | 
						|
 | 
						|
## Exemple 1: CDK Virtual Scroll pour Résultats de Recherche
 | 
						|
 | 
						|
### Fichier: `src/components/search-results/search-results.component.ts`
 | 
						|
 | 
						|
**AVANT (rendu complet):**
 | 
						|
```typescript
 | 
						|
@Component({
 | 
						|
  selector: 'app-search-results',
 | 
						|
  template: `
 | 
						|
    <div class="results-list">
 | 
						|
      @for (note of results(); track note.id) {
 | 
						|
        <app-note-card [note]="note" (click)="selectNote(note)">
 | 
						|
        </app-note-card>
 | 
						|
      }
 | 
						|
    </div>
 | 
						|
  `,
 | 
						|
  changeDetection: ChangeDetectionStrategy.OnPush
 | 
						|
})
 | 
						|
export class SearchResultsComponent {
 | 
						|
  results = input.required<Note[]>();
 | 
						|
  noteSelected = output<Note>();
 | 
						|
  
 | 
						|
  selectNote(note: Note): void {
 | 
						|
    this.noteSelected.emit(note);
 | 
						|
  }
 | 
						|
}
 | 
						|
```
 | 
						|
 | 
						|
**APRÈS (avec Virtual Scroll):**
 | 
						|
```typescript
 | 
						|
import { CdkVirtualScrollViewport, ScrollingModule } from '@angular/cdk/scrolling';
 | 
						|
 | 
						|
@Component({
 | 
						|
  selector: 'app-search-results',
 | 
						|
  imports: [CommonModule, ScrollingModule, NoteCardComponent],
 | 
						|
  template: `
 | 
						|
    <cdk-virtual-scroll-viewport
 | 
						|
      [itemSize]="80"
 | 
						|
      class="results-viewport h-full">
 | 
						|
      
 | 
						|
      @if (results().length === 0) {
 | 
						|
        <div class="empty-state p-8 text-center text-gray-500">
 | 
						|
          Aucun résultat trouvé
 | 
						|
        </div>
 | 
						|
      }
 | 
						|
      
 | 
						|
      <div *cdkVirtualFor="let note of results(); 
 | 
						|
                           trackBy: trackByNoteId; 
 | 
						|
                           templateCacheSize: 0"
 | 
						|
           class="result-item">
 | 
						|
        <app-note-card 
 | 
						|
          [note]="note" 
 | 
						|
          (click)="selectNote(note)">
 | 
						|
        </app-note-card>
 | 
						|
      </div>
 | 
						|
      
 | 
						|
    </cdk-virtual-scroll-viewport>
 | 
						|
  `,
 | 
						|
  styles: [`
 | 
						|
    .results-viewport {
 | 
						|
      height: 100%;
 | 
						|
      width: 100%;
 | 
						|
    }
 | 
						|
    .result-item {
 | 
						|
      height: 80px; /* Match itemSize */
 | 
						|
      padding: 0.5rem;
 | 
						|
    }
 | 
						|
  `],
 | 
						|
  changeDetection: ChangeDetectionStrategy.OnPush
 | 
						|
})
 | 
						|
export class SearchResultsComponent {
 | 
						|
  results = input.required<Note[]>();
 | 
						|
  noteSelected = output<Note>();
 | 
						|
  
 | 
						|
  selectNote(note: Note): void {
 | 
						|
    this.noteSelected.emit(note);
 | 
						|
  }
 | 
						|
  
 | 
						|
  // CRITICAL: trackBy optimizes change detection
 | 
						|
  trackByNoteId(index: number, note: Note): string {
 | 
						|
    return note.id;
 | 
						|
  }
 | 
						|
}
 | 
						|
```
 | 
						|
 | 
						|
**Gain attendu:** 
 | 
						|
- DOM nodes: 500 → ~15 (97% réduction)
 | 
						|
- Scroll FPS: 30 → 60
 | 
						|
- Temps rendu initial: 800ms → 50ms
 | 
						|
 | 
						|
---
 | 
						|
 | 
						|
## Exemple 2: Web Worker pour Parsing Markdown
 | 
						|
 | 
						|
### Fichier: `src/services/markdown.worker.ts` (NOUVEAU)
 | 
						|
 | 
						|
```typescript
 | 
						|
/// <reference lib="webworker" />
 | 
						|
 | 
						|
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 hljs from 'highlight.js';
 | 
						|
 | 
						|
interface ParseRequest {
 | 
						|
  id: string;
 | 
						|
  markdown: string;
 | 
						|
  options?: {
 | 
						|
    currentNotePath?: string;
 | 
						|
    attachmentsBase?: string;
 | 
						|
  };
 | 
						|
}
 | 
						|
 | 
						|
interface ParseResponse {
 | 
						|
  id: string;
 | 
						|
  html: string;
 | 
						|
  error?: string;
 | 
						|
}
 | 
						|
 | 
						|
// Initialize MarkdownIt (reuse across requests)
 | 
						|
const md = new MarkdownIt({
 | 
						|
  html: true,
 | 
						|
  linkify: false,
 | 
						|
  typographer: false,
 | 
						|
  breaks: false,
 | 
						|
  highlight: (code, lang) => {
 | 
						|
    if (lang && hljs.getLanguage(lang)) {
 | 
						|
      return hljs.highlight(code, { language: lang }).value;
 | 
						|
    }
 | 
						|
    return hljs.highlightAuto(code).value;
 | 
						|
  }
 | 
						|
});
 | 
						|
 | 
						|
md.use(markdownItAnchor, { slugify: slugify, tabIndex: false });
 | 
						|
md.use(markdownItTaskLists, { enabled: false });
 | 
						|
md.use(markdownItMultimdTable, { multiline: true, rowspan: true, headerless: true });
 | 
						|
md.use(markdownItFootnote);
 | 
						|
md.use(markdownItAttrs, {
 | 
						|
  leftDelimiter: '{',
 | 
						|
  rightDelimiter: '}',
 | 
						|
  allowedAttributes: ['id', 'class']
 | 
						|
});
 | 
						|
 | 
						|
// Message handler
 | 
						|
self.addEventListener('message', (event: MessageEvent<ParseRequest>) => {
 | 
						|
  const { id, markdown, options } = event.data;
 | 
						|
  
 | 
						|
  try {
 | 
						|
    const html = md.render(markdown);
 | 
						|
    
 | 
						|
    const response: ParseResponse = { id, html };
 | 
						|
    self.postMessage(response);
 | 
						|
  } catch (error) {
 | 
						|
    const response: ParseResponse = {
 | 
						|
      id,
 | 
						|
      html: '',
 | 
						|
      error: error instanceof Error ? error.message : String(error)
 | 
						|
    };
 | 
						|
    self.postMessage(response);
 | 
						|
  }
 | 
						|
});
 | 
						|
 | 
						|
function slugify(text: string): string {
 | 
						|
  return text
 | 
						|
    .toString()
 | 
						|
    .normalize('NFD')
 | 
						|
    .replace(/[\u0300-\u036f]/g, '')
 | 
						|
    .replace(/[^\w\s-]/g, '')
 | 
						|
    .trim()
 | 
						|
    .replace(/\s+/g, '-')
 | 
						|
    .replace(/-+/g, '-')
 | 
						|
    .toLowerCase();
 | 
						|
}
 | 
						|
```
 | 
						|
 | 
						|
### Fichier: `src/services/markdown-worker.service.ts` (NOUVEAU)
 | 
						|
 | 
						|
```typescript
 | 
						|
import { Injectable, NgZone } from '@angular/core';
 | 
						|
import { Observable, Subject, filter, map, take, timeout } from 'rxjs';
 | 
						|
 | 
						|
interface WorkerTask {
 | 
						|
  id: string;
 | 
						|
  markdown: string;
 | 
						|
  options?: any;
 | 
						|
  resolve: (html: string) => void;
 | 
						|
  reject: (error: Error) => void;
 | 
						|
}
 | 
						|
 | 
						|
@Injectable({ providedIn: 'root' })
 | 
						|
export class MarkdownWorkerService {
 | 
						|
  private workers: Worker[] = [];
 | 
						|
  private readonly WORKER_COUNT = 2; // CPU cores - 2
 | 
						|
  private currentWorkerIndex = 0;
 | 
						|
  private taskCounter = 0;
 | 
						|
  private responses$ = new Subject<{ id: string; html: string; error?: string }>();
 | 
						|
  
 | 
						|
  constructor(private zone: NgZone) {
 | 
						|
    this.initWorkers();
 | 
						|
  }
 | 
						|
  
 | 
						|
  private initWorkers(): void {
 | 
						|
    for (let i = 0; i < this.WORKER_COUNT; i++) {
 | 
						|
      const worker = new Worker(
 | 
						|
        new URL('./markdown.worker.ts', import.meta.url),
 | 
						|
        { type: 'module' }
 | 
						|
      );
 | 
						|
      
 | 
						|
      // Handle responses outside Angular zone
 | 
						|
      this.zone.runOutsideAngular(() => {
 | 
						|
        worker.addEventListener('message', (event) => {
 | 
						|
          this.zone.run(() => {
 | 
						|
            this.responses$.next(event.data);
 | 
						|
          });
 | 
						|
        });
 | 
						|
      });
 | 
						|
      
 | 
						|
      this.workers.push(worker);
 | 
						|
    }
 | 
						|
  }
 | 
						|
  
 | 
						|
  /**
 | 
						|
   * Parse markdown in worker (non-blocking)
 | 
						|
   */
 | 
						|
  parse(markdown: string, options?: any): Observable<string> {
 | 
						|
    const id = `task-${this.taskCounter++}`;
 | 
						|
    
 | 
						|
    // Round-robin worker selection
 | 
						|
    const worker = this.workers[this.currentWorkerIndex];
 | 
						|
    this.currentWorkerIndex = (this.currentWorkerIndex + 1) % this.WORKER_COUNT;
 | 
						|
    
 | 
						|
    // Send to worker
 | 
						|
    worker.postMessage({ id, markdown, options });
 | 
						|
    
 | 
						|
    // Wait for response
 | 
						|
    return this.responses$.pipe(
 | 
						|
      filter(response => response.id === id),
 | 
						|
      take(1),
 | 
						|
      timeout(5000), // 5s timeout
 | 
						|
      map(response => {
 | 
						|
        if (response.error) {
 | 
						|
          throw new Error(response.error);
 | 
						|
        }
 | 
						|
        return response.html;
 | 
						|
      })
 | 
						|
    );
 | 
						|
  }
 | 
						|
  
 | 
						|
  ngOnDestroy(): void {
 | 
						|
    this.workers.forEach(w => w.terminate());
 | 
						|
  }
 | 
						|
}
 | 
						|
```
 | 
						|
 | 
						|
### Fichier: `src/services/markdown.service.ts` (MODIFIÉ)
 | 
						|
 | 
						|
```typescript
 | 
						|
@Injectable({ providedIn: 'root' })
 | 
						|
export class MarkdownService {
 | 
						|
  private workerService = inject(MarkdownWorkerService);
 | 
						|
  
 | 
						|
  /**
 | 
						|
   * Render markdown to HTML (async via worker)
 | 
						|
   */
 | 
						|
  renderAsync(markdown: string, options?: any): Observable<string> {
 | 
						|
    return this.workerService.parse(markdown, options).pipe(
 | 
						|
      map(html => this.postProcess(html, options))
 | 
						|
    );
 | 
						|
  }
 | 
						|
  
 | 
						|
  /**
 | 
						|
   * Synchronous fallback (SSR, tests)
 | 
						|
   */
 | 
						|
  renderSync(markdown: string, options?: any): string {
 | 
						|
    // Old synchronous implementation (kept for compatibility)
 | 
						|
    const md = this.createMarkdownIt();
 | 
						|
    return this.postProcess(md.render(markdown), options);
 | 
						|
  }
 | 
						|
  
 | 
						|
  private postProcess(html: string, options?: any): string {
 | 
						|
    // Apply transformations that need note context
 | 
						|
    html = this.transformCallouts(html);
 | 
						|
    html = this.transformTaskLists(html);
 | 
						|
    html = this.wrapTables(html);
 | 
						|
    return html;
 | 
						|
  }
 | 
						|
}
 | 
						|
```
 | 
						|
 | 
						|
**Gain attendu:**
 | 
						|
- Main thread block: 500ms → <16ms
 | 
						|
- Concurrent parsing: support multiple notes
 | 
						|
- TTI improvement: ~30%
 | 
						|
 | 
						|
---
 | 
						|
 | 
						|
## Exemple 3: Lazy Import Mermaid + `runOutsideAngular`
 | 
						|
 | 
						|
### Fichier: `src/components/note-viewer/note-viewer.component.ts`
 | 
						|
 | 
						|
**AVANT:**
 | 
						|
```typescript
 | 
						|
import mermaid from 'mermaid'; // ❌ Statique, 1.2MB au boot
 | 
						|
 | 
						|
@Component({...})
 | 
						|
export class NoteViewerComponent implements AfterViewInit {
 | 
						|
  ngAfterViewInit(): void {
 | 
						|
    this.renderMermaid();
 | 
						|
  }
 | 
						|
  
 | 
						|
  private renderMermaid(): void {
 | 
						|
    const diagrams = this.elementRef.nativeElement.querySelectorAll('.mermaid-diagram');
 | 
						|
    diagrams.forEach(el => {
 | 
						|
      const code = decodeURIComponent(el.dataset.mermaidCode || '');
 | 
						|
      mermaid.render(`mermaid-${Date.now()}`, code).then(({ svg }) => {
 | 
						|
        el.innerHTML = svg;
 | 
						|
      });
 | 
						|
    });
 | 
						|
  }
 | 
						|
}
 | 
						|
```
 | 
						|
 | 
						|
**APRÈS:**
 | 
						|
```typescript
 | 
						|
import { NgZone } from '@angular/core';
 | 
						|
 | 
						|
@Component({...})
 | 
						|
export class NoteViewerComponent implements AfterViewInit {
 | 
						|
  private zone = inject(NgZone);
 | 
						|
  private mermaidLoaded = false;
 | 
						|
  private mermaidModule: typeof import('mermaid') | null = null;
 | 
						|
  
 | 
						|
  ngAfterViewInit(): void {
 | 
						|
    this.renderMermaidAsync();
 | 
						|
  }
 | 
						|
  
 | 
						|
  private async renderMermaidAsync(): Promise<void> {
 | 
						|
    const diagrams = this.elementRef.nativeElement.querySelectorAll('.mermaid-diagram');
 | 
						|
    if (diagrams.length === 0) {
 | 
						|
      return; // No mermaid diagrams, skip loading
 | 
						|
    }
 | 
						|
    
 | 
						|
    // Lazy load mermaid ONLY when needed
 | 
						|
    if (!this.mermaidLoaded) {
 | 
						|
      try {
 | 
						|
        // runOutsideAngular: avoid triggering change detection during load
 | 
						|
        await this.zone.runOutsideAngular(async () => {
 | 
						|
          const { default: mermaid } = await import('mermaid');
 | 
						|
          mermaid.initialize({
 | 
						|
            startOnLoad: false,
 | 
						|
            theme: document.documentElement.getAttribute('data-theme') === 'dark' 
 | 
						|
              ? 'dark' 
 | 
						|
              : 'default'
 | 
						|
          });
 | 
						|
          this.mermaidModule = mermaid;
 | 
						|
          this.mermaidLoaded = true;
 | 
						|
        });
 | 
						|
      } catch (error) {
 | 
						|
        console.error('[Mermaid] Failed to load:', error);
 | 
						|
        return;
 | 
						|
      }
 | 
						|
    }
 | 
						|
    
 | 
						|
    // Render diagrams outside Angular zone (heavy computation)
 | 
						|
    await this.zone.runOutsideAngular(async () => {
 | 
						|
      for (const el of Array.from(diagrams)) {
 | 
						|
        const code = decodeURIComponent((el as HTMLElement).dataset.mermaidCode || '');
 | 
						|
        try {
 | 
						|
          const { svg } = await this.mermaidModule!.render(
 | 
						|
            `mermaid-${Date.now()}-${Math.random()}`,
 | 
						|
            code
 | 
						|
          );
 | 
						|
          // Re-enter zone only for DOM update
 | 
						|
          this.zone.run(() => {
 | 
						|
            el.innerHTML = svg;
 | 
						|
          });
 | 
						|
        } catch (error) {
 | 
						|
          console.error('[Mermaid] Render error:', error);
 | 
						|
          el.innerHTML = `<pre class="text-red-500">Mermaid error: ${error}</pre>`;
 | 
						|
        }
 | 
						|
      }
 | 
						|
    });
 | 
						|
  }
 | 
						|
}
 | 
						|
```
 | 
						|
 | 
						|
**Même pattern pour MathJax:**
 | 
						|
```typescript
 | 
						|
private async renderMathAsync(): Promise<void> {
 | 
						|
  const mathElements = this.elementRef.nativeElement.querySelectorAll('.md-math-block, .md-math-inline');
 | 
						|
  if (mathElements.length === 0) return;
 | 
						|
  
 | 
						|
  await this.zone.runOutsideAngular(async () => {
 | 
						|
    const { default: mathjax } = await import('markdown-it-mathjax3');
 | 
						|
    // ... configuration and rendering
 | 
						|
  });
 | 
						|
}
 | 
						|
```
 | 
						|
 | 
						|
**Gain attendu:**
 | 
						|
- Initial bundle: -1.2MB (mermaid)
 | 
						|
- TTI: 4.2s → 2.5s
 | 
						|
- Mermaid load only when needed: lazy
 | 
						|
 | 
						|
---
 | 
						|
 | 
						|
## Exemple 4: Service `SearchMeilisearchService` + Mapping Opérateurs
 | 
						|
 | 
						|
### Fichier: `src/services/search-meilisearch.service.ts` (NOUVEAU)
 | 
						|
 | 
						|
```typescript
 | 
						|
import { Injectable, inject } from '@angular/core';
 | 
						|
import { HttpClient } from '@angular/common/http';
 | 
						|
import { Observable, map, catchError, of } from 'rxjs';
 | 
						|
 | 
						|
interface MeilisearchSearchRequest {
 | 
						|
  q: string;
 | 
						|
  filter?: string | string[];
 | 
						|
  attributesToHighlight?: string[];
 | 
						|
  highlightPreTag?: string;
 | 
						|
  highlightPostTag?: string;
 | 
						|
  limit?: number;
 | 
						|
  offset?: number;
 | 
						|
}
 | 
						|
 | 
						|
interface MeilisearchHit {
 | 
						|
  docId: string;
 | 
						|
  title: string;
 | 
						|
  path: string;
 | 
						|
  fileName: string;
 | 
						|
  content: string;
 | 
						|
  tags: string[];
 | 
						|
  _formatted?: {
 | 
						|
    title: string;
 | 
						|
    content: string;
 | 
						|
  };
 | 
						|
  _matchesPosition?: any;
 | 
						|
}
 | 
						|
 | 
						|
interface MeilisearchSearchResponse {
 | 
						|
  hits: MeilisearchHit[];
 | 
						|
  estimatedTotalHits: number;
 | 
						|
  processingTimeMs: number;
 | 
						|
  query: string;
 | 
						|
}
 | 
						|
 | 
						|
@Injectable({ providedIn: 'root' })
 | 
						|
export class SearchMeilisearchService {
 | 
						|
  private http = inject(HttpClient);
 | 
						|
  private readonly BASE_URL = '/api/search';
 | 
						|
  
 | 
						|
  /**
 | 
						|
   * Execute search with Obsidian operator mapping
 | 
						|
   */
 | 
						|
  search(query: string, options?: {
 | 
						|
    limit?: number;
 | 
						|
    vaultId?: string;
 | 
						|
  }): Observable<MeilisearchSearchResponse> {
 | 
						|
    const { filters, searchTerms } = this.parseObsidianQuery(query);
 | 
						|
    
 | 
						|
    const request: MeilisearchSearchRequest = {
 | 
						|
      q: searchTerms.join(' '),
 | 
						|
      filter: filters,
 | 
						|
      attributesToHighlight: ['title', 'content', 'headings'],
 | 
						|
      highlightPreTag: '<mark class="search-highlight">',
 | 
						|
      highlightPostTag: '</mark>',
 | 
						|
      limit: options?.limit ?? 50,
 | 
						|
      offset: 0
 | 
						|
    };
 | 
						|
    
 | 
						|
    return this.http.post<MeilisearchSearchResponse>(
 | 
						|
      `${this.BASE_URL}`,
 | 
						|
      { ...request, vaultId: options?.vaultId ?? 'primary' }
 | 
						|
    ).pipe(
 | 
						|
      catchError(error => {
 | 
						|
        console.error('[SearchMeilisearch] Error:', error);
 | 
						|
        return of({
 | 
						|
          hits: [],
 | 
						|
          estimatedTotalHits: 0,
 | 
						|
          processingTimeMs: 0,
 | 
						|
          query
 | 
						|
        });
 | 
						|
      })
 | 
						|
    );
 | 
						|
  }
 | 
						|
  
 | 
						|
  /**
 | 
						|
   * Parse Obsidian query into Meilisearch filters + search terms
 | 
						|
   */
 | 
						|
  private parseObsidianQuery(query: string): {
 | 
						|
    filters: string[];
 | 
						|
    searchTerms: string[];
 | 
						|
  } {
 | 
						|
    const filters: string[] = [];
 | 
						|
    const searchTerms: string[] = [];
 | 
						|
    
 | 
						|
    // Extract operators: tag:, path:, file:, -tag:, etc.
 | 
						|
    const tokens = query.match(/(-)?(\w+):([^\s]+)|([^\s]+)/g) || [];
 | 
						|
    
 | 
						|
    for (const token of tokens) {
 | 
						|
      // Negative tag
 | 
						|
      if (token.match(/^-tag:#?(.+)/)) {
 | 
						|
        const tag = token.replace(/^-tag:#?/, '');
 | 
						|
        filters.push(`tags != '#${tag}'`);
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
      
 | 
						|
      // Positive tag
 | 
						|
      if (token.match(/^tag:#?(.+)/)) {
 | 
						|
        const tag = token.replace(/^tag:#?/, '');
 | 
						|
        filters.push(`tags = '#${tag}'`);
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
      
 | 
						|
      // Path filter
 | 
						|
      if (token.match(/^path:(.+)/)) {
 | 
						|
        const path = token.replace(/^path:/, '');
 | 
						|
        filters.push(`folder = '${path}'`);
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
      
 | 
						|
      // File filter
 | 
						|
      if (token.match(/^file:(.+)/)) {
 | 
						|
        const file = token.replace(/^file:/, '');
 | 
						|
        filters.push(`fileName = '${file}.md'`);
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
      
 | 
						|
      // Has attachment
 | 
						|
      if (token === 'has:attachment') {
 | 
						|
        filters.push('hasAttachment = true');
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
      
 | 
						|
      // Regular search term
 | 
						|
      if (!token.startsWith('-') && !token.includes(':')) {
 | 
						|
        searchTerms.push(token);
 | 
						|
      }
 | 
						|
    }
 | 
						|
    
 | 
						|
    return { filters, searchTerms };
 | 
						|
  }
 | 
						|
  
 | 
						|
  /**
 | 
						|
   * Get autocomplete suggestions
 | 
						|
   */
 | 
						|
  suggest(query: string, type: 'tag' | 'file' | 'path'): Observable<string[]> {
 | 
						|
    return this.http.get<{ suggestions: string[] }>(
 | 
						|
      `${this.BASE_URL}/suggest`,
 | 
						|
      { params: { q: query, type } }
 | 
						|
    ).pipe(
 | 
						|
      map(response => response.suggestions),
 | 
						|
      catchError(() => of([]))
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
```
 | 
						|
 | 
						|
**Utilisation dans `SearchOrchestratorService`:**
 | 
						|
```typescript
 | 
						|
@Injectable({ providedIn: 'root' })
 | 
						|
export class SearchOrchestratorService {
 | 
						|
  private meilisearch = inject(SearchMeilisearchService);
 | 
						|
  private localIndex = inject(SearchIndexService); // Fallback
 | 
						|
  
 | 
						|
  execute(query: string, options?: SearchExecutionOptions): Observable<SearchResult[]> {
 | 
						|
    // Use Meilisearch if available, fallback to local
 | 
						|
    return this.meilisearch.search(query, options).pipe(
 | 
						|
      map(response => this.transformMeilisearchResults(response)),
 | 
						|
      catchError(error => {
 | 
						|
        console.warn('[Search] Meilisearch failed, using local index', error);
 | 
						|
        return of(this.executeLocal(query, options));
 | 
						|
      })
 | 
						|
    );
 | 
						|
  }
 | 
						|
  
 | 
						|
  private transformMeilisearchResults(response: MeilisearchSearchResponse): SearchResult[] {
 | 
						|
    return response.hits.map(hit => ({
 | 
						|
      noteId: hit.docId.split(':')[1], // Extract noteId from docId
 | 
						|
      matches: this.extractMatches(hit),
 | 
						|
      score: 100, // Meilisearch handles scoring
 | 
						|
      allRanges: []
 | 
						|
    }));
 | 
						|
  }
 | 
						|
}
 | 
						|
```
 | 
						|
 | 
						|
**Gain attendu:**
 | 
						|
- Search P95: 800ms → 50ms (16x faster)
 | 
						|
- Typo-tolerance: "porject" trouve "project"
 | 
						|
- Highlights server-side: pas de calcul frontend
 | 
						|
 | 
						|
---
 | 
						|
 | 
						|
## Exemple 5: API `/api/log` Backend + Contrat TypeScript
 | 
						|
 | 
						|
### Fichier: `server/routes/log.mjs` (NOUVEAU)
 | 
						|
 | 
						|
```javascript
 | 
						|
import express from 'express';
 | 
						|
import { promises as fs } from 'fs';
 | 
						|
import path from 'path';
 | 
						|
import { fileURLToPath } from 'url';
 | 
						|
 | 
						|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
 | 
						|
const router = express.Router();
 | 
						|
 | 
						|
const LOG_DIR = path.join(__dirname, '../logs');
 | 
						|
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB
 | 
						|
const LOG_FILE = path.join(LOG_DIR, 'client-events.jsonl');
 | 
						|
 | 
						|
// Ensure log directory exists
 | 
						|
await fs.mkdir(LOG_DIR, { recursive: true });
 | 
						|
 | 
						|
/**
 | 
						|
 * POST /api/log - Receive batch of client events
 | 
						|
 */
 | 
						|
router.post('/log', async (req, res) => {
 | 
						|
  try {
 | 
						|
    const { records } = req.body;
 | 
						|
    
 | 
						|
    if (!Array.isArray(records) || records.length === 0) {
 | 
						|
      return res.status(400).json({
 | 
						|
        accepted: 0,
 | 
						|
        rejected: 0,
 | 
						|
        errors: ['Invalid request: records must be a non-empty array']
 | 
						|
      });
 | 
						|
    }
 | 
						|
    
 | 
						|
    // Validate and sanitize records
 | 
						|
    const validRecords = [];
 | 
						|
    const errors = [];
 | 
						|
    
 | 
						|
    for (let i = 0; i < records.length; i++) {
 | 
						|
      const record = records[i];
 | 
						|
      
 | 
						|
      // Required fields
 | 
						|
      if (!record.ts || !record.event || !record.sessionId) {
 | 
						|
        errors.push(`Record ${i}: missing required fields`);
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
      
 | 
						|
      // Sanitize sensitive data
 | 
						|
      const sanitized = {
 | 
						|
        ts: record.ts,
 | 
						|
        level: record.level || 'info',
 | 
						|
        app: record.app || 'ObsiViewer',
 | 
						|
        sessionId: record.sessionId,
 | 
						|
        event: record.event,
 | 
						|
        context: sanitizeContext(record.context || {}),
 | 
						|
        data: sanitizeData(record.data || {})
 | 
						|
      };
 | 
						|
      
 | 
						|
      validRecords.push(sanitized);
 | 
						|
    }
 | 
						|
    
 | 
						|
    // Write to JSONL (one JSON per line)
 | 
						|
    if (validRecords.length > 0) {
 | 
						|
      const lines = validRecords.map(r => JSON.stringify(r)).join('\n') + '\n';
 | 
						|
      await appendLog(lines);
 | 
						|
    }
 | 
						|
    
 | 
						|
    res.json({
 | 
						|
      accepted: validRecords.length,
 | 
						|
      rejected: records.length - validRecords.length,
 | 
						|
      errors: errors.length > 0 ? errors : undefined
 | 
						|
    });
 | 
						|
    
 | 
						|
  } catch (error) {
 | 
						|
    console.error('[Log API] Error:', error);
 | 
						|
    res.status(500).json({
 | 
						|
      accepted: 0,
 | 
						|
      rejected: 0,
 | 
						|
      errors: ['Internal server error']
 | 
						|
    });
 | 
						|
  }
 | 
						|
});
 | 
						|
 | 
						|
/**
 | 
						|
 * Append to log file with rotation
 | 
						|
 */
 | 
						|
async function appendLog(content) {
 | 
						|
  try {
 | 
						|
    // Check file size, rotate if needed
 | 
						|
    try {
 | 
						|
      const stats = await fs.stat(LOG_FILE);
 | 
						|
      if (stats.size > MAX_LOG_SIZE) {
 | 
						|
        const rotated = `${LOG_FILE}.${Date.now()}`;
 | 
						|
        await fs.rename(LOG_FILE, rotated);
 | 
						|
        console.log(`[Log] Rotated log to ${rotated}`);
 | 
						|
      }
 | 
						|
    } catch (err) {
 | 
						|
      // File doesn't exist yet, create it
 | 
						|
    }
 | 
						|
    
 | 
						|
    await fs.appendFile(LOG_FILE, content, 'utf-8');
 | 
						|
  } catch (error) {
 | 
						|
    console.error('[Log] Write error:', error);
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Sanitize context (redact vault path, keep version/theme)
 | 
						|
 */
 | 
						|
function sanitizeContext(context) {
 | 
						|
  return {
 | 
						|
    version: context.version,
 | 
						|
    route: context.route?.replace(/[?&].*/, ''), // Strip query params
 | 
						|
    theme: context.theme,
 | 
						|
    // vault: redacted
 | 
						|
  };
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Sanitize data (hash sensitive fields)
 | 
						|
 */
 | 
						|
function sanitizeData(data) {
 | 
						|
  const sanitized = { ...data };
 | 
						|
  
 | 
						|
  // Hash query strings
 | 
						|
  if (sanitized.query && typeof sanitized.query === 'string') {
 | 
						|
    sanitized.query = hashString(sanitized.query);
 | 
						|
  }
 | 
						|
  
 | 
						|
  // Redact file paths
 | 
						|
  if (sanitized.path && typeof sanitized.path === 'string') {
 | 
						|
    sanitized.path = '[REDACTED]';
 | 
						|
  }
 | 
						|
  
 | 
						|
  return sanitized;
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Simple hash (not cryptographic, just obfuscation)
 | 
						|
 */
 | 
						|
function hashString(str) {
 | 
						|
  let hash = 0;
 | 
						|
  for (let i = 0; i < str.length; i++) {
 | 
						|
    const chr = str.charCodeAt(i);
 | 
						|
    hash = ((hash << 5) - hash) + chr;
 | 
						|
    hash |= 0;
 | 
						|
  }
 | 
						|
  return `hash_${Math.abs(hash).toString(16)}`;
 | 
						|
}
 | 
						|
 | 
						|
export default router;
 | 
						|
```
 | 
						|
 | 
						|
### Fichier: `server/index.mjs` (MODIFIÉ)
 | 
						|
 | 
						|
```javascript
 | 
						|
import express from 'express';
 | 
						|
import logRouter from './routes/log.mjs';
 | 
						|
 | 
						|
const app = express();
 | 
						|
 | 
						|
// Middleware
 | 
						|
app.use(express.json({ limit: '1mb' }));
 | 
						|
 | 
						|
// Routes
 | 
						|
app.use('/api', logRouter);
 | 
						|
 | 
						|
// Health endpoint
 | 
						|
app.get('/api/health', (req, res) => {
 | 
						|
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
 | 
						|
});
 | 
						|
 | 
						|
// Start server
 | 
						|
const PORT = process.env.PORT || 4000;
 | 
						|
app.listen(PORT, () => {
 | 
						|
  console.log(`[Server] Listening on port ${PORT}`);
 | 
						|
});
 | 
						|
```
 | 
						|
 | 
						|
### Fichier: `src/core/logging/log.model.ts` (Événements standardisés)
 | 
						|
 | 
						|
```typescript
 | 
						|
export type LogEvent =
 | 
						|
  // App lifecycle
 | 
						|
  | 'APP_START'
 | 
						|
  | 'APP_STOP'
 | 
						|
  
 | 
						|
  // Navigation
 | 
						|
  | 'PAGE_VIEW'
 | 
						|
  
 | 
						|
  // Search
 | 
						|
  | 'SEARCH_EXECUTED'
 | 
						|
  | 'SEARCH_OPTIONS_APPLIED'
 | 
						|
  | 'SEARCH_DIAG_START'
 | 
						|
  | 'SEARCH_DIAG_PARSE'
 | 
						|
  | 'SEARCH_DIAG_PLAN'
 | 
						|
  | 'SEARCH_DIAG_EXEC_PROVIDER'
 | 
						|
  | 'SEARCH_DIAG_RESULT_MAP'
 | 
						|
  | 'SEARCH_DIAG_SUMMARY'
 | 
						|
  | 'SEARCH_DIAG_ERROR'
 | 
						|
  
 | 
						|
  // Graph
 | 
						|
  | 'GRAPH_VIEW_OPEN'
 | 
						|
  | 'GRAPH_INTERACTION'
 | 
						|
  
 | 
						|
  // Bookmarks
 | 
						|
  | 'BOOKMARKS_OPEN'
 | 
						|
  | 'BOOKMARKS_MODIFY'
 | 
						|
  
 | 
						|
  // Calendar
 | 
						|
  | 'CALENDAR_SEARCH_EXECUTED'
 | 
						|
  
 | 
						|
  // Errors
 | 
						|
  | 'ERROR_BOUNDARY'
 | 
						|
  | 'PERFORMANCE_METRIC';
 | 
						|
 | 
						|
export interface LogRecord {
 | 
						|
  ts: string;                  // ISO 8601
 | 
						|
  level: LogLevel;
 | 
						|
  app: string;
 | 
						|
  sessionId: string;
 | 
						|
  userAgent: string;
 | 
						|
  context: LogContext;
 | 
						|
  event: LogEvent;
 | 
						|
  data: Record<string, unknown>;
 | 
						|
}
 | 
						|
 | 
						|
export interface LogContext {
 | 
						|
  version: string;
 | 
						|
  route?: string;
 | 
						|
  theme?: 'light' | 'dark';
 | 
						|
  vault?: string;
 | 
						|
}
 | 
						|
 | 
						|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
 | 
						|
```
 | 
						|
 | 
						|
**Exemple payload:**
 | 
						|
```json
 | 
						|
{
 | 
						|
  "records": [
 | 
						|
    {
 | 
						|
      "ts": "2025-10-06T15:30:00.000Z",
 | 
						|
      "level": "info",
 | 
						|
      "app": "ObsiViewer",
 | 
						|
      "sessionId": "abc-123-def-456",
 | 
						|
      "userAgent": "Mozilla/5.0...",
 | 
						|
      "context": {
 | 
						|
        "version": "1.0.0",
 | 
						|
        "route": "/",
 | 
						|
        "theme": "dark"
 | 
						|
      },
 | 
						|
      "event": "SEARCH_EXECUTED",
 | 
						|
      "data": {
 | 
						|
        "query": "tag:#project",
 | 
						|
        "queryLength": 13,
 | 
						|
        "resultsCount": 42
 | 
						|
      }
 | 
						|
    }
 | 
						|
  ]
 | 
						|
}
 | 
						|
```
 | 
						|
 | 
						|
**Gain attendu:**
 | 
						|
- Diagnostics production possibles
 | 
						|
- Corrélation événements via sessionId
 | 
						|
- Rotation automatique logs
 | 
						|
- RGPD-compliant (redaction champs sensibles)
 | 
						|
 | 
						|
---
 | 
						|
 | 
						|
## Résumé des Exemples
 | 
						|
 | 
						|
| # | Feature | Fichiers Modifiés/Créés | Gain Principal |
 | 
						|
|---|---------|-------------------------|----------------|
 | 
						|
| 1 | CDK Virtual Scroll | `search-results.component.ts` | -97% DOM nodes |
 | 
						|
| 2 | Markdown Worker | `markdown.worker.ts`, `markdown-worker.service.ts` | -500ms freeze |
 | 
						|
| 3 | Lazy Mermaid | `note-viewer.component.ts` | -1.2MB bundle |
 | 
						|
| 4 | Meilisearch Service | `search-meilisearch.service.ts` | 16x faster search |
 | 
						|
| 5 | /api/log Backend | `server/routes/log.mjs` | Diagnostics production |
 | 
						|
 | 
						|
**Tous ces exemples sont prêts à copier-coller et tester immédiatement.**
 | 
						|
 |