Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/adelpro/quran-search-engine/llms.txt

Use this file to discover all available pages before exploring further.

This example demonstrates a standalone Angular application using the Quran Search Engine library.

Features

  • Standalone Angular component (no NgModule required)
  • Reactive search with debouncing
  • Lemma, root, and fuzzy search options
  • Highlighted results by match type
  • Pagination controls
  • Type-safe implementation

Setup

1

Install dependencies

pnpm install
2

Start the development server

From the workspace root:
pnpm -C examples/angular start
Or from the example directory:
cd examples/angular
pnpm start
Angular uses start instead of dev for the development server.
3

Open your browser

Navigate to http://localhost:4200

Project structure

examples/angular/
├── src/
│   ├── app/
│   │   └── app.component.ts    # Main standalone component
│   ├── main.ts                  # Bootstrap
│   └── index.html
├── package.json
└── angular.json

Component implementation

The example uses a standalone component with inline template and styles:
app.component.ts
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
  getHighlightRanges,
  loadMorphology,
  loadQuranData,
  loadWordMap,
  search,
  type AdvancedSearchOptions,
  type MatchType,
  type MorphologyAya,
  type QuranText,
  type SearchResponse,
  type WordMap,
} from 'quran-search-engine';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule],
  template: `
    <!-- Template content -->
  `,
  styles: [`
    /* Component styles */
  `],
})
export class AppComponent implements OnInit, OnDestroy {
  // Component implementation
}
This example uses Angular 18+ standalone components, eliminating the need for NgModule declarations.

Loading data on initialization

Load all datasets when the component initializes:
app.component.ts
type LoadState = 'idle' | 'loading' | 'ready' | 'error';

export class AppComponent implements OnInit {
  loadState: LoadState = 'idle';
  errorMessage = '';

  private quranData: QuranText[] | null = null;
  private morphologyMap: Map<number, MorphologyAya> | null = null;
  private wordMap: WordMap | null = null;

  async ngOnInit(): Promise<void> {
    this.loadState = 'loading';
    this.errorMessage = '';

    try {
      const [quranData, morphologyMap, wordMap] = await Promise.all([
        loadQuranData(),
        loadMorphology(),
        loadWordMap(),
      ]);

      this.quranData = quranData;
      this.morphologyMap = morphologyMap;
      this.wordMap = wordMap;
      this.loadState = 'ready';
    } catch (err: unknown) {
      this.loadState = 'error';
      this.errorMessage = err instanceof Error ? err.message : 'Failed to load datasets.';
    }
  }
}

Search with debouncing

Implement debounced search triggered by input changes:
app.component.ts
export class AppComponent implements OnDestroy {
  query = '';
  page = 1;
  limit = 20;
  options = { lemma: true, root: true, fuzzy: true };
  response: SearchResponse<QuranText> | null = null;

  private debounceHandle: number | null = null;

  ngOnDestroy(): void {
    if (this.debounceHandle !== null) {
      window.clearTimeout(this.debounceHandle);
    }
  }

  onQueryChange(): void {
    if (this.debounceHandle !== null) {
      window.clearTimeout(this.debounceHandle);
    }

    this.debounceHandle = window.setTimeout(() => {
      this.runSearch(true);
    }, 250);
  }

  runSearch(resetPage: boolean): void {
    if (!this.quranData || !this.morphologyMap || !this.wordMap) return;

    const trimmed = this.query.trim();
    if (!trimmed) {
      this.response = null;
      return;
    }

    if (resetPage) this.page = 1;

    const searchOptions: AdvancedSearchOptions = {
      lemma: this.options.lemma,
      root: this.options.root,
      fuzzy: this.options.fuzzy,
    };

    this.response = search(
      trimmed,
      this.quranData,
      this.morphologyMap,
      this.wordMap,
      searchOptions,
      { page: this.page, limit: this.limit },
    );
  }
}

Template with search controls

Create a reactive search interface:
app.component.ts (template)
<main class="page">
  <header class="header">
    <h1 class="title">Quran Search Engine</h1>
    <p class="subtitle">Angular example</p>
  </header>

  <section class="panel" aria-label="Search controls">
    <label class="label" for="query">Arabic query</label>
    <div class="row">
      <input
        class="input"
        id="query"
        type="text"
        [disabled]="loadState !== 'ready'"
        [(ngModel)]="query"
        (ngModelChange)="onQueryChange()"
        placeholder="مثال: الرحمن"
      />
      <button
        class="button"
        type="button"
        (click)="runSearch(true)"
        [disabled]="loadState !== 'ready'"
      >
        Search
      </button>
    </div>

    <fieldset class="fieldset" [disabled]="loadState !== 'ready'">
      <legend class="legend">Options</legend>
      <label class="check">
        <input type="checkbox" [(ngModel)]="options.lemma" (ngModelChange)="runSearch(true)" />
        Lemma
      </label>
      <label class="check">
        <input type="checkbox" [(ngModel)]="options.root" (ngModelChange)="runSearch(true)" />
        Root
      </label>
      <label class="check">
        <input type="checkbox" [(ngModel)]="options.fuzzy" (ngModelChange)="runSearch(true)" />
        Fuzzy
      </label>
    </fieldset>
  </section>
</main>

Highlighting implementation

Implement highlight rendering with caching for performance:
app.component.ts
type HighlightPart = { text: string; matchType: MatchType | null };

export class AppComponent {
  private uthmaniHighlightPartsByGid = new Map<number, readonly HighlightPart[]>();

  trackByGid(_: number, verse: QuranText): number {
    return verse.gid;
  }

  getUthmaniParts(verse: ScoredVerse<QuranText>): readonly HighlightPart[] {
    return (
      this.uthmaniHighlightPartsByGid.get(verse.gid) ?? [{ text: verse.uthmani, matchType: null }]
    );
  }

  private rebuildHighlightCache(): void {
    this.uthmaniHighlightPartsByGid.clear();
    if (!this.response) return;

    for (const verse of this.response.results) {
      const parts = this.buildHighlightParts(verse.uthmani, verse.matchedTokens, verse.tokenTypes);
      this.uthmaniHighlightPartsByGid.set(verse.gid, parts);
    }
  }

  private buildHighlightParts(
    text: string,
    matchedTokens: readonly string[],
    tokenTypes?: Record<string, MatchType>,
  ): readonly HighlightPart[] {
    const ranges = getHighlightRanges(text, matchedTokens, tokenTypes);
    if (ranges.length === 0) return [{ text, matchType: null }];

    const parts: HighlightPart[] = [];
    let cursor = 0;

    for (const range of ranges) {
      if (cursor < range.start) {
        parts.push({ text: text.slice(cursor, range.start), matchType: null });
      }
      parts.push({ text: text.slice(range.start, range.end), matchType: range.matchType });
      cursor = range.end;
    }

    if (cursor < text.length) {
      parts.push({ text: text.slice(cursor), matchType: null });
    }

    return parts;
  }
}
The example caches highlight parts by verse gid to avoid recalculating them on every change detection cycle.

Display results with highlighting

Render highlighted verses in the template:
app.component.ts (template)
<ol class="list">
  <li *ngFor="let verse of response.results; trackBy: trackByGid" class="item">
    <div class="itemHead">
      <span class="badge" [attr.data-type]="verse.matchType">{{ verse.matchType }}</span>
      <span class="ref">{{ verse.sura_name_en }} • {{ verse.aya_id_display }}</span>
      <span class="score">score {{ verse.matchScore }}</span>
    </div>
    <p class="text arabic" dir="rtl">
      <span
        *ngFor="let part of getUthmaniParts(verse)"
        [class]="part.matchType ? 'highlight-' + part.matchType : ''"
      >
        {{ part.text }}
      </span>
    </p>
    <p class="text muted">{{ verse.standard }}</p>
  </li>
</ol>

Pagination controls

Implement page navigation:
app.component.ts
goToPage(nextPage: number): void {
  const target = Math.max(1, nextPage);
  if (target === this.page) return;
  this.page = target;
  this.runSearch(false);
}
app.component.ts (template)
<div class="pager" aria-label="Pagination controls">
  <button
    class="button secondary"
    type="button"
    (click)="goToPage(page - 1)"
    [disabled]="page <= 1"
  >
    Prev
  </button>
  <span class="muted">Page {{ page }} / {{ response.pagination.totalPages || 1 }}</span>
  <button
    class="button secondary"
    type="button"
    (click)="goToPage(page + 1)"
    [disabled]="page >= (response.pagination.totalPages || 1)"
  >
    Next
  </button>
</div>

Dependencies

The example uses these key dependencies:
package.json
{
  "dependencies": {
    "@angular/common": "^18.0.0",
    "@angular/core": "^18.0.0",
    "@angular/forms": "^18.0.0",
    "@angular/platform-browser": "^18.0.0",
    "quran-search-engine": "workspace:*",
    "rxjs": "^7.8.0",
    "zone.js": "^0.14.0"
  },
  "devDependencies": {
    "@angular/cli": "^18.0.0",
    "@angular/compiler-cli": "^18.0.0",
    "typescript": "~5.5.0"
  }
}

Key features demonstrated

  • Standalone components: Modern Angular architecture without NgModules
  • Two-way binding: Using [(ngModel)] for reactive forms
  • Async operations: Proper async/await handling in lifecycle hooks
  • Performance optimization: Highlight caching and trackBy for efficient rendering
  • Accessibility: ARIA labels and semantic HTML
  • Type safety: Full TypeScript integration with Angular

Build docs developers (and LLMs) love