---
title: "Canlı Arama ve Otomatik Tamamlama"
url: "https://cilginyazilim.com/kutuphane/php-search-autocomplete"
description: "Bu örnek, PHP 8 ve MySQL ile yazılmış üretime hazır bir canlı arama ve otomatik tamamlama modülüdür. Arama, LIKE yüzde işaretli kalıp yerine InnoDB FULLTEXT indeksi üzerinden yapılır ve boolean modda çalışır; böylece önek eşleşmesi mümkün olur ve kullanıcı henüz yazarken sonuç döner. Önek eşleşmesi Türkçe için ayrıca önemlidir: dil eklemeli olduğu için indeks, indeksin ve indeksleri ayrı sözcüklerdir ve natural language modu bunları birbirine bağlamaz. Ham kullanıcı girdisi hiçbir zaman AGAINST ifadesine doğrudan verilmez; boolean modda artı, eksi, yıldız ve tırnak karakterlerinin hepsi operatördür ve bir tire işareti aramayı sessizce bozabilir. Üç harften kısa terimlerde uygulama bilinçli olarak LIKE yoluna düşer, çünkü InnoDB varsayılan olarak kısa sözcükleri indekslemez ve boolean mod sessizce sıfır sonuç döndürür; hangi modun çalıştığı ekranda yazar. İstemci tarafında iki ayrı sorun çözülür: debounce istek sayısını düşürür, sıra numarası ise yarış durumunu kapatır — yavaş dönen eski bir yanıtın yeni sonucu ezmesi debounce ile engellenemez. Türkçe harf katlaması birebir bir eşleme tablosuyla yapılır, çünkü PHP büyük İ harfini küçültürken iki kod noktası üretir ve vurgulama konumları kayar. Vurgulama üç adımlıdır: ham metinde ayraçla işaretle, tamamını kaçışla, en son ayraçları mark etiketine çevir; ters sıra HTML varlıklarını bozar ve XSS riski doğurur. Ayrıca kategori facet sayıları, otomatik tamamlama için ayrı bir başlık indeksi, sonuç bulunamadığında terim düşürme önerisi, klavye gezinmesi, URL senkronu ve paylaşılabilir derin bağlantı bulunur."
published: "2026-08-30T19:28:06+03:00"
modified: "2026-09-05T01:41:58+03:00"
category: "Arayüz ve Tablo"
type: "kod örneği"
difficulty: "Orta"
tech: ["PHP 8", "PDO", "MySQL", "FULLTEXT", "Ajax", "Bootstrap 5"]
tags: ["Ajax", "Arama", "Debounce", "FULLTEXT", "Otomatik Tamamlama", "PDO", "Performans", "Türkçe"]
license: "MIT"
site: "CılgınYazılım"
language: "tr"
---

# Canlı Arama ve Otomatik Tamamlama

Bu örnek, PHP 8 ve MySQL ile yazılmış üretime hazır bir canlı arama ve otomatik tamamlama modülüdür. Arama, LIKE yüzde işaretli kalıp yerine InnoDB FULLTEXT indeksi üzerinden yapılır ve boolean modda çalışır; böylece önek eşleşmesi mümkün olur ve kullanıcı henüz yazarken sonuç döner. Önek eşleşmesi Türkçe için ayrıca önemlidir: dil eklemeli olduğu için indeks, indeksin ve indeksleri ayrı sözcüklerdir ve natural language modu bunları birbirine bağlamaz. Ham kullanıcı girdisi hiçbir zaman AGAINST ifadesine doğrudan verilmez; boolean modda artı, eksi, yıldız ve tırnak karakterlerinin hepsi operatördür ve bir tire işareti aramayı sessizce bozabilir. Üç harften kısa terimlerde uygulama bilinçli olarak LIKE yoluna düşer, çünkü InnoDB varsayılan olarak kısa sözcükleri indekslemez ve boolean mod sessizce sıfır sonuç döndürür; hangi modun çalıştığı ekranda yazar. İstemci tarafında iki ayrı sorun çözülür: debounce istek sayısını düşürür, sıra numarası ise yarış durumunu kapatır — yavaş dönen eski bir yanıtın yeni sonucu ezmesi debounce ile engellenemez. Türkçe harf katlaması birebir bir eşleme tablosuyla yapılır, çünkü PHP büyük İ harfini küçültürken iki kod noktası üretir ve vurgulama konumları kayar. Vurgulama üç adımlıdır: ham metinde ayraçla işaretle, tamamını kaçışla, en son ayraçları mark etiketine çevir; ters sıra HTML varlıklarını bozar ve XSS riski doğurur. Ayrıca kategori facet sayıları, otomatik tamamlama için ayrı bir başlık indeksi, sonuç bulunamadığında terim düşürme önerisi, klavye gezinmesi, URL senkronu ve paylaşılabilir derin bağlantı bulunur.

- **Gereksinim:** PHP 8.0+ (pdo_mysql, mbstring) · MySQL 5.6+ veya MariaDB 10.0.5+ (InnoDB FULLTEXT) · Apache/mod_rewrite
- **Kod deposu:** https://github.com/CilginYazilim/search-autocomplete

## Öne Çıkan Özellikler

- MySQL FULLTEXT indeksi ile arama; LIKE %kelime% gibi tam tablo taraması yapmaz
- Boolean mod ve önek eşleşmesi: "perfor" yazınca "performans" bulunur
- Türkçe eklemeli bir dil olduğu için önek eşleşmesi "indeksin" ve "indeksleri" biçimlerini de yakalar
- Ham girdi asla AGAINST() içine konmaz; boolean modda artı, eksi ve yıldız karakterleri operatördür
- Üç harften kısa terimlerde bilinçli LIKE geri dönüşü ve modun ekranda gösterilmesi
- Alaka skoru sonuç kartında görünür; sıralama alaka, en yeni ve en eski olarak seçilebilir
- Kategori facet sayıları, kategori filtresi uygulanmadan hesaplanır
- Sonuç bulunamazsa terim düşürerek sonuç veren daha kısa bir sorgu önerilir
- Debounce ile 10 tuş vuruşu tek isteğe iner
- Sıra numarasıyla yarış koruması: yavaş dönen eski yanıt yeni sonucu ezemez
- Debounce yarış durumunu çözmez; ikisi farklı sorunlardır ve ayrı çözüm ister
- Türkçe harf katlaması birebirdir; mb_strtolower İ harfinde iki kod noktası döndürüp vurgulama konumlarını kaydırıyordu
- Vurgulama sırası: ham metinde işaretle, sonra kaçışla, en son mark etiketine çevir
- Otomatik tamamlama yalnızca başlıklarda arar; bunun için ayrı bir FULLTEXT indeksi vardır
- Klavye gezinmesi, URL senkronu, paylaşılabilir derin bağlantı ve mobilde sıfır yatay kaydırma

## Kullanım Senaryoları

- Blog, haber ve dokümantasyon sitelerinde site içi arama
- Yönetim panellerinde kayıt arama ve hızlı erişim kutusu
- E-ticarette ürün arama ve otomatik tamamlama
- Bilgi bankası ve destek merkezi araması
- Elasticsearch kurmadan önce MySQL ile ne kadar yol alınabileceğini ölçmek
- Türkçe metinlerde harf katlaması ve ek sorununu çözen bir arama katmanı

## Kurulum

1. MySQL sürümünüzü kontrol edin: InnoDB FULLTEXT için 5.6+ (MariaDB 10.0.5+) gerekir.
2. Depoyu indirin ya da ZIP olarak açın.
3. Veritabanını kurun: mysql -u root -p < cy_search.sql (dosya veritabanını kendisi oluşturur).
4. Projeyi bir web sunucusu altına koyun ya da php -S 127.0.0.1:8000 ile çalıştırın.
5. Tarayıcıda açın; 38 yazı ve 6 kategori dolu bir arama ekranı gelir.
6. Örnek arama düğmesiyle akışı deneyin, sonra iki harflik bir terim yazıp LIKE moduna düşüşü görün.
7. Canlıya alırken system/config.local.php.example dosyasını config.local.php olarak kopyalayıp künyeyi oraya yazın.

## Ayrıntılı Anlatım

<img src="assets/images/logo.png" alt="Çılgın Yazılım" width="90">

## Canlı Arama ve Otomatik Tamamlama

#### PHP PDO · MySQL FULLTEXT · AJAX · Bootstrap 5 · Çılgın Yazılım Tasarım Kalıbı

**Yazarken arayan, ne bulduğunu ve nasıl bulduğunu söyleyen bir arama kutusu.**

[![PHP](https://img.shields.io/badge/PHP-8.0%2B-777BB4?style=flat-square&logo=php&logoColor=white)](https://php.net)
[![MySQL](https://img.shields.io/badge/MySQL-5.6%2B-4479A1?style=flat-square&logo=mysql&logoColor=white)](https://mysql.com)
[![Bootstrap](https://img.shields.io/badge/Bootstrap-5.2-7952B3?style=flat-square&logo=bootstrap&logoColor=white)](https://getbootstrap.com)
[![Bağımlılık](https://img.shields.io/badge/Bağımlılık-Sıfır-16a34a?style=flat-square)](#kurulum)
[![License](https://img.shields.io/badge/Lisans-MIT-16a34a?style=flat-square)](LICENSE)

**🇹🇷 Türkçe** · [🇬🇧 English](README.en.md)

[**▶ Canlı Demo**](https://cilginyazilim.com/kutuphane/uygulama/search-autocomplete/) · [Kaynak Kütüphanesi](https://cilginyazilim.com/kutuphane/php-search-autocomplete) · [cilginyazilim.com](https://cilginyazilim.com)

---

### Canlı Demo

**Kurulum yok, kayıt yok, indirme yok — tarayıcınızdan 3 saniyede deneyin.**

<a href="https://cilginyazilim.com/kutuphane/uygulama/search-autocomplete/"><img src="https://img.shields.io/badge/CANLI_DEMOYU_A%C3%87-0b5cb5?style=for-the-badge&logo=googlechrome&logoColor=white&labelColor=061321" alt="Canlı Demoyu Aç" height="42"></a>
<a href="https://cilginyazilim.com/kutuphane/php-search-autocomplete"><img src="https://img.shields.io/badge/KAYNAK_KODU_%C4%B0NCELE-0ea5e9?style=for-the-badge&logo=readthedocs&logoColor=white&labelColor=061321" alt="Kaynak Kodu İncele" height="42"></a>
<a href="https://github.com/CilginYazilim/search-autocomplete/archive/refs/heads/main.zip"><img src="https://img.shields.io/badge/ZIP_%C4%B0ND%C4%B0R-16a34a?style=for-the-badge&logo=github&logoColor=white&labelColor=061321" alt="ZIP İndir" height="42"></a>

<br><br>

<a href="https://cilginyazilim.com/kutuphane/uygulama/search-autocomplete/" title="Canlı demoyu açmak için tıklayın">
  <img src="docs/screenshots/01-canli-arama.png" alt="Canlı arama demo önizlemesi" width="860">
</a>

<sub>▲ Görsele tıklayarak demoyu açabilirsiniz</sub>

<br>

#### Demoda 60 saniyede neleri deneyebilirsiniz?

| # | Şunu deneyin | Perde arkasında ne oluyor? |
|---|--------------|----------------------------|
| **1** | **▶ Örnek arama** düğmesine basın | `performans` aratılır: 8 sonuç, 6 kategorinin 5'ine dağılmış. Eşleşen sözcükler hem başlıkta hem özette **vurgulanır** |
| **2** | Ölçüm satırına bakın: `FULLTEXT boolean · 48 ms` | Hangi modun çalıştığı ve ne kadar sürdüğü ekranda yazar. Bu satır eğitseldir — arama motorunun kararını görünür kılar |
| **3** | Kutuya `perf` yazın | Öneri listesi açılır ve **yalnızca başlıklarda** arar. Gövdeye de bakan bir öneri listesi, başlığında o kelime hiç geçmeyen yazıları önerirdi |
| **4** | <kbd>↓</kbd> <kbd>↓</kbd> <kbd>Enter</kbd> ile bir öneri seçin | Fare olmadan çalışır. <kbd>Esc</kbd> listeyi kapatır, ok tuşları listede döner (sondan sonra başa) |
| **5** | `guvenlik` yazın — şapkasız, düz harflerle | Yine de **güvenlik** bulunur ve vurgulanır. Türkçe harf katlaması hem MySQL karşılaştırmasında hem vurgulama kodunda ayrı ayrı çözülmüştür |
| **6** | `ci` yazın (iki harf) | Mod **LIKE**'a düşer ve bunu söyler. MySQL'in FULLTEXT eşiği 3 harftir; kısa terimde sessizce sıfır sonuç döndürmek yerine yavaş ama doğru yolu seçiyoruz |
| **7** | `mysql kuyruk react` aratın | Sonuç yok — ama ekran boş kalmaz: sunucu terim düşürerek **sonuç veren daha kısa bir sorgu önerir** ve kaç sonuç vereceğini söyler |
| **8** | Bir **kategori çipine** tıklayın | Facet sayıları filtre uygulanmadan hesaplanır; "Web 2"ye tıkladığınızda diğer kategoriler **0'a düşmez**, geri dönebilirsiniz |
| **9** | Adres çubuğuna bakın | `?q=performans&sayfa=2` yazar. Bağlantıyı gönderdiğinizde karşı taraf **aynı sonuçları** görür. Bir sonuca tıklayınca `#makale-7` eklenir |
| **10** | Telefonunuzdan açın | Arama kutusu **küçültülmez** — dokunmatikte asıl hedef odur. Skor rozeti gizlenir, yatay kaydırma yoktur |

> **İpucu:** Demoyu açıkken **F12 → Network** sekmesini açın. Yazarken `autocomplete` ve `search` isteklerinin nasıl **debounce** edildiğini, her isteğe verilen `seq` numarasını ve HTTP durum kodlarını (200 / 403 / 405 / 429) canlı görebilirsiniz.

#### Demo alanı hakkında bilinmesi gerekenler

| Konu | Durum |
|------|-------|
| **Veriler** | `cy_search.sql` içindeki **38 yazı, 6 kategori**. Metinler bilinçli olarak birbirine değer: "performans" beş kategoride geçer, böylece facet sayaçları anlamlı olur. |
| **Sıfırlama** | Demo veritabanı **düzenli aralıklarla** başlangıç haline döner. |
| **MySQL sürümü** | FULLTEXT indeksi InnoDB'de **MySQL 5.6+** / **MariaDB 10.0.5+** ile gelir. |
| **Arama kaydı** | **Tutulmuyor.** Önerilen aramalar bir geçmiş tablosundan değil, yazıların **etiketlerinden** üretilir. |
| **`APP_DEBUG`** | Canlıda **otomatik `false`** — sunucu adından türetilir, yerelde `true` kalır. |
| **Bağımlılık** | **Sıfır.** Composer yok, npm yok, CDN yok, arama kütüphanesi yok — ne Elasticsearch ne de bir JS autocomplete paketi. |

> Demo geçici olarak kapalıysa endişelenmeyin: depoyu klonlayıp `cy_search.sql`'i içe aktarmanız aynı ekranı kendi bilgisayarınızda **2 dakikada** ayağa kaldırır → [Kurulum](#kurulum)

---

### Bu Proje Nedir?

Arama kutusu, bir panelin en çok kullanılan ve en az düşünülen parçasıdır. Tipik uygulaması tek satırdır:

```sql
SELECT * FROM articles WHERE title LIKE '%$q%'
```

Bu satırın dört ayrı sorunu vardır ve hiçbiri hemen görünmez:

1. **İndeks kullanamaz.** Baştaki `%` yüzünden MySQL her satırı okur. Bin satırda fark edilmez, yüz binde sayfa donar.
2. **Sıralama yoktur.** Hangi sonucun daha alakalı olduğu bilinmez; tablo sırası neyse o gelir.
3. **Her tuş vuruşunda çalışır.** "performans" yazmak 10 istek demektir.
4. **Yanıtlar sırasız döner.** `per` isteği yavaş, `performans` isteği hızlı dönerse ekranda **yanlış sonuç** kalır — ve bu sorunu debounce çözmez.

Bu proje dördünü de ele alıyor: **FULLTEXT indeksi** ile alaka skoruna göre arıyor, **boolean mod önek eşleşmesiyle** kullanıcı henüz yazarken sonuç veriyor, **debounce** ile istek sayısını düşürüyor ve **sıra numarasıyla** yarış durumunu kapatıyor.

Bir de beşinci bir şey yapıyor: **ne yaptığını söylüyor.** Hangi modun çalıştığı (FULLTEXT mi LIKE mı), kaç sonuç bulunduğu ve sorgunun kaç milisaniye sürdüğü ekranda yazar. Bu satır gerçek bir projede kaldırılabilir — ama bir örneğin işi, kararlarını görünür kılmaktır.

**Kimler için uygun?**

- Kendi paneline arama kutusu ekleyecek ve `LIKE '%...%'` ile başlamak istemeyenler
- Elasticsearch kurmadan önce MySQL'in ne kadarını yapabileceğini merak edenler
- Türkçe metinde arama yapan herkes (harf katlaması, ekler, önek eşleşmesi)
- Otomatik tamamlamayı **kütüphanesiz** ve klavye erişilebilir yazmak isteyenler
- Bootstrap 5 üzerine kurulu, tekrar kullanılabilir bir tasarım kalıbı arayanlar

> **Klonla, `cy_search.sql`'i içe aktar, çalıştır.** Başka hiçbir kurulum adımı yok. Composer yok, npm yok, internet bağlantısı bile gerekmiyor — tüm kütüphaneler proje içinde.

Bu proje, **[Çılgın Yazılım Kütüphanesi](https://cilginyazilim.com/kutuphane)** altında yayınlanan açıklamalı, üretime hazır örneklerden biridir.

---

### İçindekiler

- [Canlı Demo](#canlı-demo)
- [Ekran Görüntüleri](#ekran-görüntüleri)
- [Beş Kritik Karar](#beş-kritik-karar)
- [Neler Var?](#neler-var)
- [Güvenlik: Neyi, Nasıl Kapattık?](#güvenlik-neyi-nasıl-kapattık)
- [Kurulum](#kurulum)
- [Yapılandırma](#yapılandırma)
- [Kendi Projenize Eklemek](#kendi-projenize-eklemek)
- [Çılgın Yazılım Tasarım Kalıbı](#çılgın-yazılım-tasarım-kalıbı)
- [Dosya Yapısı](#dosya-yapısı)
- [Nasıl Çalışıyor?](#nasıl-çalışıyor)
- [AJAX API Referansı](#ajax-api-referansı)
- [Veritabanı Şeması](#veritabanı-şeması)
- [Sık Sorulanlar](#sık-sorulanlar)
- [Canlı Ortama Alırken](#canlı-ortama-alırken)
- [Sorun Giderme](#sorun-giderme)
- [Yol Haritası](#yol-haritası)
- [Katkı](#katkı)
- [Lisans](#lisans)

---

### Ekran Görüntüleri

#### Canlı arama

Vurgulanmış sonuçlar, alaka skoru, kategori facet'leri ve ölçüm satırı. Sayaç şeridi hem veri kümesini (38 yazı, 6 kategori) hem o anki aramayı (8 sonuç, 48 ms, FULLTEXT boolean) gösterir.

![Canlı arama](docs/screenshots/01-canli-arama.png)

#### Otomatik tamamlama ve klavye

Öneri listesi **yalnızca başlıklarda** arar ve eşleşen kısmı vurgular. Klavyeyle seçili öğe, fare ile üzerine gelinen öğeyle **aynı** görünür — kullanıcı girdi yöntemini değiştirdiğinde arayüzün dili değişmemelidir.

![Öneri listesi](docs/screenshots/02-oneri-listesi.png)

#### Yazı detayı

Sonuç kartı bir özet gösterir; tıklayınca yazının tamamı açılır ve arama sözcükleri **burada da** vurgulanır. Adres çubuğu `#makale-7` olur; bağlantı paylaşılabilir. Etiketlere tıklamak yeni bir arama başlatır.

![Yazı detayı](docs/screenshots/03-yazi-detay.png)

#### Mobil görünüm

Arama kutusu **küçültülmez** — dokunmatik ekranda asıl hedef odur. Skor rozeti gizlenir, facet çipleri sarar, yatay kaydırma yoktur.

<img src="docs/screenshots/04-mobil.png" alt="Mobil görünüm" width="360">

---

### Beş Kritik Karar

#### 1) LIKE değil FULLTEXT — ve neden boolean mod

`LIKE '%kelime%'` indeks kullanamaz: baştaki joker yüzünden MySQL tabloyu baştan sona okur. FULLTEXT indeksi ise kelimeleri önceden ayrıştırıp saklar ve **alaka skoru** üretir.

FULLTEXT'in iki modu vardır ve bu proje **boolean** modu seçer:

| Mod | Sözdizimi | Ne yapar |
|-----|-----------|----------|
| Natural language | `AGAINST('yapay zeka')` | Kelimeleri VEYA'lar, skora göre sıralar |
| **Boolean** | `AGAINST('+yapay* +zeka*' IN BOOLEAN MODE)` | Operatör ve **önek eşleşmesi** destekler |

Boolean modun seçilme sebebi tek bir kelimede özetlenir: **`*`**. Canlı aramada kullanıcı henüz yazmaktadır; `perfor` yazdığında `performans` bulunmalıdır. Önek eşleşmesi yalnızca boolean modda vardır.

Türkçe için ikinci bir sebep daha var: dil eklemeli olduğu için `indeks`, `indeksin`, `indeksleri` üç ayrı sözcüktür ve natural language modu bunları birbirine bağlamaz. `indeks*` üçünü de yakalar — kök bulmaya (stemming) yaklaşan ücretsiz bir yaklaşıklık.

#### 2) Ham girdi asla `AGAINST()` içine konmaz

Boolean modda `+ - * " ( ) ~ < > @` karakterlerinin **hepsi operatördür**. Kullanıcının yazdığını olduğu gibi geçirmek iki farklı hataya yol açar:

- `C++` araması **sözdizimi hatası** verir
- `-güvenlik` yazan kullanıcı, farkında olmadan o kelimeyi **dışlar** ve neden sonuç bulamadığını anlayamaz

Çözüm, harf ve rakam dışındaki her şeyi atmak; operatörleri **biz** eklemek:

```php
function boolean_query(string $raw): string
{
    $clean = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $raw) ?? '';
    $words = preg_split('/\s+/u', trim($clean), -1, PREG_SPLIT_NO_EMPTY) ?: [];

    $terms = [];
    foreach ($words as $w) {
        if (mb_strlen($w) < 2) { continue; }
        $terms[] = '+' . $w . '*';      // +kelime*  → zorunlu + önek
    }
    return implode(' ', $terms);
}
```

#### 3) Debounce yarış durumunu ÇÖZMEZ

Canlı aramanın iki ayrı sorunu vardır ve çoğu örnek yalnızca birincisini çözer.

**Sorun 1 — çok fazla istek.** Çözüm debounce: yazma durduktan 200 ms sonra tek istek.

**Sorun 2 — yanıtların sırasız dönmesi.** Debounce bunu azaltır ama **kapatmaz**. Ağ gecikmesi eşit değildir:

```
gönderilen:  1(per) ────────────────────→ yavaş
             2(perf) ──────→ hızlı
             3(performans) ─→ hızlı
dönen:       2 ✔ → 3 ✔ → 1 ✗  (1 < 3 olduğu için ATILIR)
```

Bu koruma olmasaydı ekranda `per` sonuçları kalırdı — kullanıcı doğru yazmıştır ama yanlış sonucu görür. Çözüm, her isteğe artan bir numara vermek ve sunucunun onu aynen geri döndürmesi:

```js
if (res.seq < renderedSeq) { return; }   // eski yanıt — at
renderedSeq = res.seq;
```

Kararın **istemcide** verilmesi zorunludur: "en son hangi isteği gönderdim" bilgisi yalnızca orada vardır.

#### 4) Türkçe harf katlaması iki ayrı yerde çözülür

`IŞIK` araması `ışık` kelimesini bulmalı ve **vurgulamalıdır**. Bunlar iki farklı sorundur:

- **Bulmak** MySQL'in işidir ve `utf8mb4_unicode_ci` karşılaştırması bunu hallederi
- **Vurgulamak** PHP'nin işidir ve orada iki tuzak vardır

Birincisi bilinen tuzak: `mb_strtolower('I')` → `i` verir, ama Türkçede `I`nın küçüğü `ı`dır. İkincisi daha sinsi ve arayanı uzun süre uğraştırır:

```php
mb_strlen('İ')                     // 1
mb_strlen(mb_strtolower('İ'))      // 2  ← i + birleştirici nokta!
```

Vurgulama konumları **karakter indisine** dayandığı için, katlanmış metnin uzunluğu orijinalden farklı olduğunda bütün işaretler kayar. Bu yüzden kendi eşlememizi yazıyoruz — ve tablo **birebir**dir:

```php
function tr_fold(string $s): string
{
    $map = ['İ'=>'i','I'=>'i','ı'=>'i','Ş'=>'s','ş'=>'s','Ğ'=>'g','ğ'=>'g',
            'Ü'=>'u','ü'=>'u','Ö'=>'o','ö'=>'o','Ç'=>'c','ç'=>'c'];

    return mb_strtolower(strtr($s, $map), 'UTF-8');
}
```

#### 5) Vurgulama sırası: önce işaretle, sonra kaçışla

İlk akla gelen yol çalışıyor gibi görünür ama **HTML varlıklarını bozar**:

```php
// YANLIŞ: önce kaçışla, sonra <mark> ekle
$safe = htmlspecialchars($text);
$safe = str_replace($word, '<mark>' . $word . '</mark>', $safe);
```

Metinde `&` geçtiğinde kaçışlanmış hâli `&amp;` olur. Kullanıcı `amp` aratırsa vurgulama tam o varlığın ortasına girer:

```
&amp;   →   &<mark>amp</mark>;      ← bozuk HTML, ekranda ham "&amp;"
```

Doğru sıra üç adımdır:

1. **Ham** metinde eşleşmeleri bul, yerlerine `\x00 … \x01` gibi metinde asla bulunmayacak ayraçlar koy
2. Metnin tamamını kaçışla (ayraçlar da güvenle taşınır)
3. Ayraçları `<mark>` ve `</mark>` ile değiştir

```php
$out = str_replace(["\x00", "\x01"], ['<mark>', '</mark>'], e($prefix . $out . $suffix));
```

Bu, vurgulama özelliğinin XSS'e dönüşmeden çalışmasını sağlayan şeydir: çıktıdaki tek HTML etiketi bizim koyduğumuzdur.

---

### Neler Var?

<tr><td valign="top" width="50%">

**Arama çekirdeği**

- MySQL **FULLTEXT** indeksi, boolean mod, **önek eşleşmesi**
- Ham girdi güvenli bir boolean ifadeye çevrilir (operatör enjeksiyonu yok)
- 3 harften kısa terimlerde bilinçli **LIKE geri dönüşü** — ve bunu söyler
- Alaka skoru ekranda; sıralama **alaka / en yeni / en eski** (beyaz liste)
- Kategori **facet** sayıları, filtre uygulanmadan hesaplanır
- Sonuç yoksa **terim düşürerek** sonuç veren bir sorgu önerir
- Türkçe harf katlaması (`tr_fold`) — birebir, indis kaydırmaz
- Özet penceresi ilk eşleşmenin çevresinden kesilir
- Öneri listesi **yalnızca başlıklarda** arar (ayrı FULLTEXT indeksi)

</td><td valign="top" width="50%">

**Arayüz ve altyapı**

- **Debounce** (200 ms) + **sıra numarasıyla yarış koruması**
- Klavye gezinmesi: <kbd>↑</kbd> <kbd>↓</kbd> <kbd>Enter</kbd> <kbd>Esc</kbd>, döngüsel
- `role="combobox"` + `aria-activedescendant` ile ekran okuyucu desteği
- Vurgulama XSS'e dönüşmeden: işaretle → kaçışla → etikete çevir
- URL senkronu: `?q=...&kategori=...&sirala=...&sayfa=...`
- Derin bağlantı `#makale-7`, paylaşılabilir yazı detayı
- Önerilen aramalar, arama kaydı tutmadan **etiketlerden** üretilir
- Sayaç şeridi + ölçüm satırı (mod ve süre)
- İki ayrı hız sınırı kovası (autocomplete / search)
- Mobil: 767 / 480px eşikleri, **yatay kaydırma yok**

</td></tr>

---

### Güvenlik: Neyi, Nasıl Kapattık?

| Açık | Tipik hatalı kod | Bu projede |
|------|------------------|------------|
| **SQL Injection** | `"... LIKE '%".$_POST['q']."%'"` | Tüm sorgular prepared statement, `EMULATE_PREPARES = false`. Sıralama SQL'e metin olarak girdiği için `SORT_WHITELIST` **beyaz listesinden** geçer. |
| **FULLTEXT operatör enjeksiyonu** | `AGAINST($q IN BOOLEAN MODE)` | Ham girdide `+ - * " ( ) ~` **operatördür**. `boolean_query()` harf/rakam dışını atar; operatörleri yalnızca kod ekler. |
| **XSS (vurgulanan metinde)** | `$('#r').html(row.title)` | Vurgulama **ham metinde ayraçla** yapılır, sonra tamamı kaçışlanır, en son ayraçlar `<mark>`'a çevrilir. Çıktıdaki tek etiket bizim koyduğumuzdur. |
| **CSRF** | *(genelde hiç yok)* | Oturuma bağlı 32 baytlık token, **her** istekte, `hash_equals()` ile sabit zamanlı doğrulama. Token'sız istek → **HTTP 403**. |
| **LIKE joker karakterleri** | `LIKE '%$q%'` | `%`, `_`, `\` kaçışlanır — `%` arayan kullanıcı tüm tabloyu değil, `%` içeren satırları bulur. |
| **Kaynak tüketimi (DoS)** | Her tuş vuruşunda sorgu | `MIN_QUERY_LEN`, `SEARCH_PAGE_SIZE`, `AUTOCOMPLETE_LIMIT` ve **iki ayrı hız sınırı kovası**. Otomatik tamamlama sık ama ucuz, arama seyrek ama pahalıdır; tek kova ikisine de haksızlık ederdi. |
| **Bozuk kodlamanın yanıtı yutması** | `json_encode()` → `false` → boş gövde | `JSON_INVALID_UTF8_SUBSTITUTE`: tek bozuk bayt yüzünden "sonuç yok" denmez. |
| **Bilgi sızıntısı** | Ekrana basılan MySQL hataları | `APP_DEBUG` sunucu adından türetilir; canlıda otomatik `false`, detay `error_log()`'a gider. |
| **Kurulum dosyasının indirilmesi** | `/cy_search.sql` → HTTP 200 | `.htaccess`: `.sql`, `.md`, `.json`, `.log` … kapalı (README dosyaları bilinçli istisnadır). |

---

### Kurulum

> Sadece görmek istiyorsanız kurulum gerekmez → [**Canlı Demoyu açın**](https://cilginyazilim.com/kutuphane/uygulama/search-autocomplete/). Aşağıdaki adımlar projeyi kendi bilgisayarınızda çalıştırmak içindir (~2 dakika).

#### Gereksinimler

- PHP **8.0+** (`pdo_mysql` ve `mbstring` eklentileri)
- **MySQL 5.6+** veya **MariaDB 10.0.5+** — *InnoDB FULLTEXT indeksi için*
- Apache (XAMPP / WAMP / Laragon) — ya da PHP'nin yerleşik sunucusu

#### Adımlar

**1 — Projeyi indirin**

```bash
git clone https://github.com/CilginYazilim/search-autocomplete.git
cd search-autocomplete
```

**2 — Veritabanını oluşturun**

`cy_search.sql` veritabanını da kendisi oluşturur; önceden `cy_search` adında bir veritabanı açmanıza gerek yok.

```bash
mysql -u root -p < cy_search.sql
```

> **İsteğe bağlı — kendi veritabanı bilgileriniz:**
> `cp .env.example .env` (Windows: `copy .env.example .env`) deyip `DB_*`
> satırlarını doldurun. Bu dosya olmadan da çalışır; varsayılanlar yerel bir
> XAMPP kurulumuna (`root`, boş parola) göredir. `.env` `.gitignore`
> içindedir — parolanız depoya gitmez.

phpMyAdmin ile: **İçe Aktar → Dosya seç → `cy_search.sql` → Başlat**

**3 — Çalıştırın**

```bash
php -S 127.0.0.1:8000
```

XAMPP kullanıyorsanız projeyi `htdocs` altına koyup şu adresi açın:
`http://localhost/search-autocomplete/`

**4 — Tarayıcıda açın** → `http://127.0.0.1:8000/`

Karşınıza **38 yazı** dolu, çalışır durumda bir arama ekranı gelecek. **▶ Örnek arama** düğmesine basarak hemen deneyebilirsiniz.

#### Ortam değişkenleri

Depo kökündeki **`.env`** dosyasına yazın; `system/config.php` dosyasına
hiç dokunmayın:

```bash
cp .env.example .env        # Windows: copy .env.example .env
```

`.env` `.gitignore` içindedir: depoya gönderilmez ve dağıtım (deploy) onu
**silmez**. `system/config.php` ise depoda durur ve her dağıtımda depodaki
sürümle değiştirilir — parolayı oraya yazarsanız hem GitHub'a gider hem de
ilk deploy'da kaybolur.

Dosyayı hiç oluşturmasanız da uygulama çalışır; aşağıdaki varsayılanlar
yerel bir XAMPP kurulumuna göredir.

**Değer arama sırası:** `.env` → sunucunun gerçek ortam değişkeni
(Apache `SetEnv`, systemd…) → buradaki varsayılan.

| Değişken | Varsayılan | Ne işe yarar |
|---|---|---|
| `DB_HOST` | `127.0.0.1` | Veritabanı sunucusu |
| `DB_NAME` | `cy_search` | Veritabanı adı |
| `DB_USER` | `root` | Kullanıcı |
| `DB_PASS` | *(boş)* | Şifre — **koda yazmayın** |
| `APP_TIMEZONE` | `Europe/Istanbul` | PHP'nin saat dilimi |
| `APP_DEBUG` | *ortamdan* | Hataların ekrana basılıp basılmayacağı |

**`APP_TIMEZONE` neden var?** XAMPP'ın `php.ini` dosyasındaki
`date.timezone`, MySQL'in kullandığı sistem diliminden farklı olabilir.
Test makinesinde PHP `Europe/Berlin`, MySQL `Europe/Istanbul`
kullanıyordu; aynı anı anlatan iki satır bir saat farklı görünüyordu.
Zaman **hesapları** SQL tarafında yapıldığı için doğruydu, ama ekrana
basılan saat kayıyordu. Artık dilim açıkça sabitleniyor — sunucunuz başka
bir bölgedeyse bu değişkeni tanımlamanız yeterli, koda dokunmayın.

---

### Yapılandırma

Tüm ayarlar [system/config.php](system/config.php) içindedir.

| Sabit | Varsayılan | Ne işe yarar |
|-------|-----------|--------------|
| `MIN_QUERY_LEN` | `2` | Bu uzunluğun altında arama yapılmaz (istemciye de taşınır) |
| `SEARCH_PAGE_SIZE` | `6` | Sonuç sayfası boyutu |
| `AUTOCOMPLETE_LIMIT` | `7` | Açılır öneri sayısı |
| `SNIPPET_LEN` | `240` | Sonuç kartındaki özet uzunluğu (karakter) |
| `SORT_WHITELIST` | `alaka, yeni, eski` | İzin verilen sıralamalar (**beyaz liste**) |
| `RATE_LIMIT_AUTOCOMPLETE` | `[180, 60]` | Öneri isteği sınırı (sık ama ucuz) |
| `RATE_LIMIT_SEARCH` | `[90, 60]` | Arama isteği sınırı (seyrek ama pahalı) |

#### `MIN_QUERY_LEN` iki yerde tutulmaz

Sunucudaki sabit `index.php` tarafından istemciye taşınır:

```php
window.CY_MIN_LEN = <?= (int) MIN_QUERY_LEN ?>;
```

Elle kopyalanmış bir sayı olsaydı, biri değişip diğeri unutulduğunda kullanıcı sunucunun "çok kısa" dediği bir sorgu için boş bir liste görür ve nedenini anlamazdı.

#### FULLTEXT sözcük eşiğini değiştirmek

InnoDB varsayılan olarak **3 harften kısa** sözcükleri indekslemez. Bunu düşürmek MySQL yapılandırmasında yapılır ve **indeksin yeniden oluşturulmasını** gerektirir:

```ini
[mysqld]
innodb_ft_min_token_size = ••••••••
```

```sql
-- MySQL yeniden başlatıldıktan sonra:
ALTER TABLE articles DROP INDEX ft_articles_title_body;
ALTER TABLE articles ADD FULLTEXT ft_articles_title_body (title, body);
```

Bu proje eşiği değiştirmez; bunun yerine kısa terimlerde LIKE'a düşer ve hangi modda çalıştığını söyler. Sebebi basit: paylaşılan bir sunucuda MySQL yapılandırmasına erişiminiz olmayabilir.

#### Şifreyi koda yazmayın

Canlı veritabanı künyesi `config.php`'ye **yazılmaz**: o dosya depoda durur ve her dağıtımda depodaki sürümle değiştirilir. Bunun yerine yanına `config.local.php` koyun (`.gitignore` içindedir):

```bash
cp system/config.local.php.example system/config.local.php
# sonra dört satırı doldurun
```

Öncelik sırası: **`config.local.php` → `.env` → ortam değişkeni → yerel varsayılan.**

---

### Kendi Projenize Eklemek

Arama katmanını başka bir tabloya (ürün, belge, kişi) taşımak için:

**1 — FULLTEXT indeksini ekleyin**

`MATCH()` içindeki sütun listesi bir indeksin sütun listesiyle **birebir** eşleşmelidir:

```sql
ALTER TABLE products
  ADD FULLTEXT ft_products_name_desc (name, description),
  ADD FULLTEXT ft_products_name (name);   -- öneri listesi için
```

İkinci indeks olmadan `MATCH(name)` yazamazsınız ve MySQL şunu der: *Can't find FULLTEXT index matching the column list*.

**2 — Sorgulardaki tablo ve sütun adlarını değiştirin**

`handle_search()` ve `handle_autocomplete()` içindeki üç sorgu. Yer tutucu adlandırma düzenini (`:q_facet`, `:q_cnt`, `:q_sel`, `:q_whr`) **bozmayın** — sebebi aşağıdaki tuzakta.

**3 — Facet sütununu seçin**

Bu projede `category`. Sizde `brand`, `status` ya da `department` olabilir. Facet sorgusuna kategori filtresi **uygulanmaz**; bu bilinçlidir.

**4 — Vurgulanacak alanları belirleyin**

`highlight()` alan bağımsızdır; hangi metni verirseniz onu işaretler. Detayda `null` uzunluk vererek tam metni alabilirsiniz.

> **Atlamayın:** `boolean_query()` fonksiyonunu kopyalamadan `AGAINST()` kullanmayın. Ham girdiyi boolean moda vermek, kullanıcının yazdığı bir tire işaretinin aramayı sessizce bozmasına yeter.

---

### Çılgın Yazılım Tasarım Kalıbı

[assets/css/cilginyazilim.css](assets/css/cilginyazilim.css) dosyası bu projeye değil **markaya** aittir. Diğer örnek projelerde de aynı görsel dili kullanabilmek için ayrı bir dosya olarak tutulur; bu örneğe özgü her şey (arama kutusu, öneri listesi, sonuç kartları, sayfalama) [assets/css/style.css](assets/css/style.css) içindedir.

#### Hazır bileşenler

| Sınıf | Ne işe yarar |
|-------|--------------|
| `.cy-card` / `.cy-card__header` / `__body` / `__footer` | Gradyan başlıklı ana kart |
| `.cy-brand` / `.cy-brand__mark` / `__title` / `__subtitle` | Logo kutusu + başlık bloğu |
| `.cy-btn` + `--primary` \| `--onbrand` \| `--glass` | Marka butonları |
| `.cy-badge` + `--glass` \| `--soft` | Rozetler |
| `.cy-modal` | Gradyan başlıklı modal |
| `.cy-toast` + `--success` \| `--danger` \| `--info` | Bildirim balonları |
| `.cy-topbar` / `.cy-footer-note` | Üst şerit ve alt bilgi bloğu |

**Bu örneğe özgü** (`style.css`): `.cy-searchbox` / `__icon` / `__clear`, `.cy-ac` / `__cat`, `.cy-chip`, `.cy-meta` / `__mode` / `__time`, `.cy-result` / `__title` / `__score` / `__snippet`, `.cy-pager` / `.cy-page`, `.cy-empty-state`, `.cy-stats` / `.cy-stat`, `mark`.

#### Renkleri değiştirmek

Tüm bileşenler CSS değişkenlerinden beslenir. **Tek yeri** değiştirmek yeter:

```css
:root {
    --cy-brand-900: #061321;   /* Logodaki en koyu lacivert */
    --cy-brand-600: #0b5cb5;   /* Ana marka mavisi          */
    --cy-accent:    #0ea5e9;   /* Vurgu rengi               */
    --cy-gradient:  linear-gradient(135deg, #061321, #0b5cb5 45%, #0284c7);
}
```

#### Koyu tema

Kullanıcının işletim sistemi koyu temadaysa **otomatik** devreye girer. Zorlamak isterseniz:

```html
<html data-cy-theme="dark">   <!-- veya "light" -->
```

---

### Dosya Yapısı

```
search-autocomplete/
├── index.php                 → Arayüz. Veritabanına DOKUNMAZ; tek kart + bir modal.
├── .env.example              → Veritabanı bilgileri (isteğe bağlı) — .gitignore içinde
├── cy_search.sql             → Şema + 38 yazı (NOW() - INTERVAL) + iki FULLTEXT indeksi
├── .htaccess                 → Dizin listeleme kapalı, .sql/.md kapalı (README istisna)
│
├── system/
│   ├── config.php            → Ayarlar, PDO bağlantısı, APP_DEBUG türetimi
│   ├── config.local.php      → (siz oluşturursunuz) canlı künye — .gitignore'da
│   ├── config.local.php.example
│   ├── function.php          → CSRF, hız sınırı, Türkçe katlama, boolean sorgu, vurgulama
│   ├── ajax.php              → 4 uç nokta
│   └── .htaccess             → BEYAZ LİSTE: yalnızca ajax.php dışarıya açık
│
├── assets/
│   ├── css/
│   │   ├── cilginyazilim.css → MARKA KALIBI (projeler arası ortak — dokunmayın)
│   │   ├── style.css         → Yalnızca bu örneğe özgü stiller
│   │   └── bootstrap.min.css
│   ├── js/
│   │   ├── search.js         → Debounce, yarış koruması, klavye, URL senkronu (8 bölüm)
│   │   ├── jquery-3.7.0.js
│   │   └── bootstrap.bundle.js
│   └── images/logo.png
│
├── docs/screenshots/         → README'de kullanılan görseller
├── CHANGELOG.md
├── README.md · README.en.md
└── LICENSE                   → MIT
```

---

### Nasıl Çalışıyor?

```
KULLANICI YAZIYOR: p → e → r → f → o → r
                              │
                              ▼
┌─ DEBOUNCE (200 ms) ──────────────────────────────────────────────────┐
│  clearTimeout(timer) → setTimeout(…, 200)                            │
│  10 tuş vuruşu → 1 istek                                             │
└──────────────────────────────────────────────────────────────────────┘
                              │
              ┌───────────────┴───────────────┐
              ▼                               ▼
┌─ autocomplete ───────────────┐  ┌─ search ─────────────────────────────┐
│ MATCH(title) AGAINST(…)      │  │ 1) facet:  GROUP BY category         │
│ ft_articles_title indeksi    │  │    (kategori filtresi UYGULANMAZ)    │
│ yalnızca BAŞLIKLAR           │  │ 2) count:  filtre UYGULANMIŞ toplam  │
│ LIMIT 7                      │  │ 3) page:   skor + LIMIT off, len     │
│                              │  │ 4) 0 sonuç → terim düşürme önerisi   │
│ ↳ seq geri döner             │  │ ↳ seq, mod, took_ms geri döner       │
└──────────────────────────────┘  └──────────────────────────────────────┘
              │                               │
              ▼                               ▼
┌─ YARIŞ KORUMASI ─────────────────────────────────────────────────────┐
│  if (res.seq < renderedSeq) return;   ← eski yanıt ATILIR            │
│  renderedSeq = res.seq;                                              │
└──────────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─ ÇİZİM ──────────────────────────────────────────────────────────────┐
│  · vurgulanmış başlık ve özet  (.html() — sunucuda kaçışlanmış)      │
│  · kategori ve tarih           (esc()  — ham veri)                   │
│  · facet çipleri, sayfalama, ölçüm satırı                            │
│  · history.replaceState → ?q=…&kategori=…&sayfa=…                    │
└──────────────────────────────────────────────────────────────────────┘
```

#### Sorumluluk dağılımı

| Katman | Dosya | Sorumluluk |
|--------|-------|-----------|
| Sunum | `index.php` | Yalnızca HTML iskeleti. Veri yok, sorgu yok. |
| Arayüz mantığı | `assets/js/search.js` | Debounce, yarış koruması, klavye, URL, çizim |
| Uç noktalar | `system/ajax.php` | İstek doğrulama, sorgular, facet, öneri zinciri |
| Alan mantığı | `system/function.php` | Türkçe katlama, boolean sorgu, vurgulama |
| Ayar | `system/config.php` | Sabitler, PDO, `APP_DEBUG` türetimi |

---

### AJAX API Referansı

Tüm istekler `system/ajax.php` adresine **POST** ile gider ve **CSRF token** taşır.

<details>
<summary><b><code>search</code> — sonuç listesi + facet</b></summary>

**İstek:** `q`, `seq`, `page`, `category`, `sort`

```json
{
  "success": true,
  "seq": 7,
  "q": "performans",
  "mode": "FULLTEXT boolean",
  "sort": "alaka",
  "total": 8,
  "all_total": 8,
  "page": 1,
  "pages": 2,
  "facets": [{"category": "Veritabanı", "count": 3}, {"category": "Web", "count": 2}],
  "results": [
    {"id": 37, "title": "Mobil <mark>performans</mark>: ilk boyama süresi",
     "snippet": "… <mark>Performans</mark> bir özellik değil…",
     "category": "Mobil", "published": "31.07.2026", "score": 0.92}
  ],
  "did_you_mean": null,
  "took_ms": 48.1
}
```

`all_total` kategori filtresi **uygulanmadan** önceki toplamdır; "Tümü" çipinde o kullanılır. `total` ise filtre uygulanmış hâlidir. İkisini karıştırmak, bir kategoriye tıklandığında "Tümü (3)" gibi yanlış bir sayı gösterirdi.

Sonuç yoksa `did_you_mean` dolu gelir:

```json
{"total": 0, "did_you_mean": {"q": "mysql", "total": 2}}
```
</details>

<details>
<summary><b><code>autocomplete</code> — öneri listesi</b></summary>

**İstek:** `q`, `seq`

```json
{
  "success": true, "seq": 12, "q": "perf",
  "suggestions": [
    {"id": 37, "title": "Mobil performans: ilk boyama süresi",
     "category": "Mobil",
     "html": "Mobil <mark>perf</mark>ormans: ilk boyama süresi"}
  ]
}
```

`title` ham metindir (kutuya yazılır), `html` vurgulanmış hâldir (listede gösterilir). İki ayrı alan olmasının sebebi budur: biri `.val()`, diğeri `.html()` ile kullanılır.
</details>

<details>
<summary><b><code>detail</code> — tek yazı</b></summary>

**İstek:** `id`, `q` (vurgulama için)

```json
{
  "success": true,
  "article": {
    "id": 7, "title": "SQL enjeksiyonu ve prepared statement",
    "body": "Kullanıcı verisi asla … en sık görülen <mark>güvenlik</mark> açığıdır.",
    "category": "Güvenlik", "tags": ["sql", "injection", "pdo", "guvenlik"],
    "published": "28.08.2026", "words": 34
  }
}
```

Gövde **kırpılmaz**: `highlight()` fonksiyonuna `null` uzunluk verilir, yani pencere yoktur.
</details>

<details>
<summary><b><code>stats</code> — sayaçlar ve önerilen aramalar</b></summary>

```json
{"success": true, "total": 38, "categories": 6, "newest": "28.08.2026",
 "min_len": 2, "suggested": ["performans", "guvenlik", "llm", "mysql", "arama"]}
```

`suggested` bir arama geçmişi tablosundan değil, yazıların **etiketlerinden** üretilir; en sık geçen sekiz etiket döner.
</details>

#### Çok kısa sorgu bir hata değildir

`q` uzunluğu `MIN_QUERY_LEN` altındaysa sunucu **200** ile ve `too_short: true` ile döner. **400 dönmez** — kullanıcı henüz yazmaktadır ve tarayıcı konsolunu kırmızı hatalarla doldurmanın bir anlamı yoktur.

#### HTTP durum kodları

| Kod | Anlamı |
|-----|--------|
| `200` | İşlem başarılı (`too_short: true` de 200'dür) |
| `400` | Geçersiz parametre ya da bilinmeyen işlem |
| `403` | CSRF token geçersiz veya oturum düşmüş |
| `404` | Yazı bulunamadı |
| `405` | POST dışı istek |
| `429` | Hız sınırı aşıldı (`retry_after` saniye döner) |
| `500` | Sunucu / veritabanı hatası (FULLTEXT indeksi eksik olabilir) |

---

### Veritabanı Şeması

```sql
CREATE TABLE `articles` (
  `id`           INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `title`        VARCHAR(200) NOT NULL,
  `body`         TEXT NOT NULL,
  `category`     VARCHAR(60)  NOT NULL,
  `tags`         VARCHAR(255) NOT NULL DEFAULT '',
  `published_at` DATE NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_articles_category` (`category`),
  KEY `idx_articles_published` (`published_at`),
  FULLTEXT KEY `ft_articles_title_body` (`title`, `body`),
  FULLTEXT KEY `ft_articles_title` (`title`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

| Karar | Neden |
|-------|-------|
| **İki sütunlu TEK indeks** (`title, body`) | `MATCH()` içindeki sütun listesi, bir FULLTEXT indeksinin sütun listesiyle **birebir** eşleşmelidir. Ayrı ayrı tanımlanmış iki indeks `MATCH(title, body)` sorgusuna hizmet **edemez** |
| **İkinci indeks** (`title`) | Öneri listesi yalnızca başlıklarda arar ve bunun için `MATCH(title)` yazması gerekir. Gövdeye de bakan bir öneri listesi, `perf` yazan kullanıcıya başlığında o kelime hiç geçmeyen yazıları öneriyordu |
| **`tags` indekslenmez** | Etiketler aranmaz, yalnızca öneri çipi üretmek için okunur. Etiketle arama istenseydi `tags` de MATCH listesine girer ve indeks yeniden oluşturulurdu |
| **`idx_articles_published`** | "En yeni / en eski" sıralamasının dayandığı indeks |
| **`idx_articles_category`** | Kategori filtresi ve facet `GROUP BY`'ı bu indeksi kullanır |
| **`utf8mb4_unicode_ci`** | FULLTEXT eşleşmesi karşılaştırma kuralına göre çalışır; `ü`/`u` ve `İ`/`i` katlaması buradan gelir |
| **InnoDB** | FULLTEXT desteği InnoDB'ye MySQL 5.6 ile geldi; öncesinde yalnızca MyISAM'da vardı |

---

### Sık Sorulanlar

<details>
<summary><b>Ne zaman Elasticsearch'e geçmeliyim?</b></summary>

MySQL FULLTEXT şunları **yapmaz**: kök bulma (stemming), eş anlamlı sözlüğü, yazım denetimi ("bunu mu demek istediniz?"), coğrafi arama, çok dilli analiz zincirleri ve gerçek anlamda ölçeklenen dağıtık indeks.

Bunlara ihtiyacınız yoksa —ki çoğu panel aramasında yoktur— MySQL yeter ve ayrı bir servis işletmek zorunda kalmazsınız. Yüz binlerce belgede ve kelime bazlı aramada FULLTEXT hâlâ makul çalışır.

Geçiş sinyalleri: eş anlamlıya ihtiyaç duymaya başladığınızda, sorgu süreleri kabul edilemez olduğunda, ya da aramanın kendisi ürünün ana özelliği hâline geldiğinde.
</details>

<details>
<summary><b>Neden natural language modu değil boolean mod?</b></summary>

Tek sebep yeter: **önek eşleşmesi**. Canlı aramada kullanıcı henüz yazmaktadır; `perfor` yazdığında `performans` bulunmalıdır. `*` operatörü yalnızca boolean modda vardır.

Türkçe için ikinci bir sebep daha var: dil eklemeli olduğu için `indeks`, `indeksin`, `indeksleri` üç ayrı sözcüktür. `indeks*` üçünü de yakalar; natural language modu yakalamaz.

Bedeli: boolean mod varsayılan olarak alaka skorunu natural language kadar iyi hesaplamaz ve %50 eşiği gibi kolaylıkları yoktur. Bu örnek için doğru ödünleşim.
</details>

<details>
<summary><b>"ci" gibi kısa terimler neden LIKE'a düşüyor?</b></summary>

InnoDB'de `innodb_ft_min_token_size` varsayılan olarak **3**'tür: daha kısa sözcükler indekse **hiç girmez**. `ci` araması boolean modda sessizce sıfır sonuç döndürür — hata yok, sadece boşluk. Kullanıcı için bu, aramanın bozuk olduğu anlamına gelir.

Bu yüzden sorgudaki en uzun sözcük 3 harften kısaysa LIKE'a düşüyoruz. LIKE indeks kullanamaz ve yavaştır; ama kısa bir terimde **doğru cevap vermek**, hızlı biçimde hiçbir şey dönmemekten iyidir. Hangi modun çalıştığı ekranda da yazar.

Eşiği düşürmek mümkündür ama MySQL yapılandırmasına erişim ve indeksin yeniden oluşturulmasını gerektirir (bkz. [Yapılandırma](#yapılandırma)).
</details>

<details>
<summary><b>Debounce varken yarış koruması gerçekten gerekli mi?</b></summary>

Evet, çünkü ikisi **farklı sorunları** çözer. Debounce istek sayısını azaltır; ağ gecikmesinin eşit olmasını sağlamaz.

200 ms sonra `perf` isteği gider. Kullanıcı yazmaya devam eder, 200 ms sonra `performans` isteği gider. İkinci istek 40 ms'de, birinci istek 600 ms'de dönerse ekranda **`perf` sonuçları kalır**. Debounce bunu engellemez; yalnızca daha seyrek yaşanmasını sağlar.

Kaçınılması kolay, fark edilmesi zor bir hatadır: yerelde neredeyse hiç görülmez, gerçek ağda düzenli olarak görülür.
</details>

<details>
<summary><b>Vurgulama neden sunucuda yapılıyor, istemcide değil?</b></summary>

İki sebep. Birincisi, özet penceresi zaten sunucuda kesiliyor: 900 karakterlik bir gövdeden ilk eşleşmenin çevresini almak, hangi kelimelerin arandığını bilmeyi gerektirir. İstemcide yapmak, gövdenin tamamını ağdan geçirmek demektir.

İkincisi ve daha önemlisi güvenlik: kaçışlama ile vurgulamanın **sırası** kritiktir (bkz. [5. karar](#5-vurgulama-sırası-önce-işaretle-sonra-kaçışla)). Bunu tek bir yerde, sunucuda doğru yapmak; her çizim noktasında istemcide tekrar doğru yapmaya çalışmaktan güvenlidir.
</details>

<details>
<summary><b>Önerilen aramalar nereden geliyor? Arama kaydı tutuyor musunuz?</b></summary>

Hayır, bir arama geçmişi tablosu yok. Öneriler **yazıların etiketlerinden** üretilir: en sık geçen sekiz etiket alınır.

Bu bilinçli bir tercih. Arama kaydı tutmak kişisel veri toplamaktır ve ayrı bir sorumluluk getirir; ayrıca yeni kurulmuş bir sistemde geçmiş boştur ve öneri kutusu haftalarca boş kalır. İçerikten türeyen öneriler ilk günden anlamlıdır.
</details>

<details>
<summary><b>Facet sayıları neden kategori filtresi uygulanmadan hesaplanıyor?</b></summary>

Çünkü facet'in işi "buradan **nereye gidebilirim**?" sorusunu cevaplamaktır. "Web" kategorisine tıkladığınızda diğer kategorilerin sayısı 0 görünseydi, oradan başka bir kategoriye geçemezdiniz — tek çıkış yolu filtreyi tamamen temizlemek olurdu.

Bu yüzden iki ayrı sayı vardır: `all_total` (filtresiz toplam, "Tümü" çipinde) ve `total` (filtreli toplam, ölçüm satırında).
</details>

---

### Canlı Ortama Alırken

- [ ] `APP_DEBUG` zaten ortamdan türetiliyor — yine de canlıda `false` olduğunu **doğrulayın**
- [ ] `system/config.local.php` oluşturun; künyeyi `config.php`'ye **yazmayın**
- [ ] Veritabanı için `root` yerine **sınırlı yetkili** (yalnızca `SELECT`) bir kullanıcı oluşturun — bu uygulama hiçbir şey yazmaz
- [ ] FULLTEXT indekslerinin gerçekten oluştuğunu doğrulayın: `SHOW INDEX FROM articles`
- [ ] Ölçüm satırını (`mod` ve `ms`) göstermek istemiyorsanız `renderResults()` içinden kaldırın
- [ ] Hız sınırı kovalarını trafiğinize göre gözden geçirin; otomatik tamamlama beklediğinizden sık çağrılır
- [ ] **HTTPS** kullanın; `session.cookie_secure = 1` ve `session.cookie_httponly = 1` ayarlayın
- [ ] Tablo büyüdükçe `EXPLAIN` ile sorgu planını kontrol edin; facet `GROUP BY`'ı ilk yavaşlayan yerdir
- [ ] Nginx kullanıyorsanız `.htaccess` çalışmaz; `.sql` ve `.md` erişimini sunucu yapılandırmasından kapatın:
  ```nginx
  location ~* \.(sql|log|ini|bak)$ { deny all; }
  ```

---

### Sorun Giderme

| Belirti | Çözüm |
|---------|-------|
| **Hiçbir arama sonuç vermiyor** | FULLTEXT indeksi oluşmamış olabilir: `SHOW INDEX FROM articles WHERE Index_type = 'FULLTEXT'`. `cy_search.sql`'i yeniden içe aktarın. |
| **Türkçe kelimeler bulunamıyor** | Veritabanı utf8mb4 değil ya da SQL dosyası `SET NAMES utf8mb4` olmadan aktarılmış. Bu durumda indeks **bozuk baytlar** üzerine kurulur ve hata sessizdir. Dosyayı yeniden yükleyin. |
| **3 harften kısa terimler bulunamıyor** | Beklenen davranış: `innodb_ft_min_token_size` varsayılan 3'tür. Uygulama bu durumda LIKE'a düşer ve ekranda modu yazar. |
| **"Invalid parameter number" hatası** | Bir FULLTEXT ifadesi aynı sorguda iki kez geçiyor ve **aynı yer tutucu adını** kullanıyor. `EMULATE_PREPARES = false` altında her yer tutucu benzersiz olmalıdır (bkz. `handle_search()` yorumları). |
| **Öneri listesi açılmıyor** | Yazdığınız terim `MIN_QUERY_LEN` altında olabilir. Ya da `?q=` ile gelen sayfada liste bilinçli olarak açılmaz. |
| **Öneriler alakasız görünüyor** | Öneri listesi yalnızca **başlıklarda** arar. Aradığınız kelime yalnızca gövdede geçiyorsa sonuç listesinde çıkar, öneride çıkmaz. |
| **Vurgulama kayıyor / bozuk karakter çıkıyor** | `tr_fold()` değiştirilmiş olabilir. Eşleme **birebir** olmalıdır: bir karakter tam olarak bir karaktere dönüşmeli, yoksa indisler kayar. |
| **HTTP 403 dönüyor** | Oturum düşmüş — sayfayı yenileyin. Sunucuda `session.save_path` yazılabilir olmalıdır. |
| **HTTP 429 dönüyor** | Hız sınırı. Otomatik tamamlama kovası dakikada 180 istektir; otomatik bir test aracı bunu kolayca aşar. |
| **`$ is not defined`** | JavaScript yükleme sırası bozulmuş. jQuery **her zaman** en başta gelmelidir. |

---

### Yol Haritası

- [ ] Eş anlamlı sözlüğü (`arama` ↔ `search`, `veritabanı` ↔ `database`)
- [ ] Yazım denetimi ve "bunu mu demek istediniz?" (trigram benzerliği)
- [ ] Tarih aralığı filtresi ve çoklu kategori seçimi
- [ ] Sonsuz kaydırma seçeneği (anahtar tabanlı sayfalama ile)
- [ ] Arama sonuçlarının CSV / JSON dışa aktarımı
- [ ] Başlığı gövdeden ağırlıklı sayan bileşik skor (iki `MATCH` toplamı)
- [ ] PHPUnit testleri (`boolean_query`, `tr_fold`, `highlight`, `should_use_like`)
- [ ] Koyu tema için elle açma/kapama düğmesi

---

### Katkı

**Bu proje herkese açıktır — dilediğiniz geliştirmeyle katkı sağlayabilirsiniz.**

📦 **Depo:** [github.com/CilginYazilim/search-autocomplete](https://github.com/CilginYazilim/search-autocomplete)

| Nasıl katkı sağlarım? | Nereden |
|----------------------|---------|
| 🐛 Hata bildir | [Issues](https://github.com/CilginYazilim/search-autocomplete/issues) |
| 💡 Özellik öner | [Issues](https://github.com/CilginYazilim/search-autocomplete/issues) |
| 🔧 Kod gönder | [Pull Requests](https://github.com/CilginYazilim/search-autocomplete/pulls) |
| ❓ Soru sor | [Discussions](https://github.com/CilginYazilim/search-autocomplete/discussions) |

#### Katkı ölçütleri

- **Kod açıklamalı olsun.** Bu projenin temel amacı öğretmek; yorumsuz kod PR'ı geri döner.
- **Ham girdiyi `AGAINST()` içine koymayın.** `boolean_query()` bu projenin güvenlik sınırıdır.
- **Vurgulama sırasını değiştirmeyin.** Önce işaretle, sonra kaçışla, en son etikete çevir — ters sıra XSS'e ya da bozuk HTML'e açılır.
- **Yarış korumasını kaldırmayın.** Yerelde hiç görülmeyen, üretimde düzenli görülen bir hatayı kapatır.
- **Tasarım değişikliklerini** `style.css` üzerinden yapın; `cilginyazilim.css` markaya aittir ve diğer projelerle **ortaktır**.

---

### Lisans

[MIT](LICENSE) — ticari kullanım dahil serbesttir.

#### Önce bir deneyin

<a href="https://cilginyazilim.com/kutuphane/uygulama/search-autocomplete/"><img src="https://img.shields.io/badge/CANLI_DEMOYU_A%C3%87-0b5cb5?style=for-the-badge&logo=googlechrome&logoColor=white&labelColor=061321" alt="Canlı Demoyu Aç" height="42"></a>
<a href="https://cilginyazilim.com/kutuphane"><img src="https://img.shields.io/badge/D%C4%B0%C4%9EER_%C3%96RNEKLER-061321?style=for-the-badge&logo=bookstack&logoColor=white&labelColor=061321" alt="Diğer Örnekler" height="42"></a>

**[cilginyazilim.com](https://cilginyazilim.com)** tarafından ❤ ile geliştirildi

## Sık Sorulan Sorular

### Ne zaman Elasticsearch gerekir, MySQL FULLTEXT nereye kadar yeter?

MySQL FULLTEXT kök bulma, eş anlamlı sözlüğü, yazım denetimi, coğrafi arama ve dağıtık indeks sunmaz. Bunlara ihtiyacınız yoksa — çoğu panel aramasında yoktur — MySQL yeter ve ayrı bir servis işletmek zorunda kalmazsınız. Yüz binlerce belgede ve kelime bazlı aramada FULLTEXT hâlâ makul çalışır. Geçiş sinyalleri: eş anlamlıya ihtiyaç duymaya başlamak, sorgu sürelerinin kabul edilemez olması, ya da aramanın ürünün ana özelliği hâline gelmesi.

### Neden natural language modu değil boolean mod kullanılıyor?

Tek sebep yeter: önek eşleşmesi. Canlı aramada kullanıcı henüz yazmaktadır ve "perfor" yazdığında "performans" bulunmalıdır; yıldız operatörü yalnızca boolean modda vardır. Türkçe için ikinci bir sebep daha var: dil eklemeli olduğu için indeks, indeksin ve indeksleri üç ayrı sözcüktür ve natural language modu bunları bağlamaz. Bedeli, boolean modun alaka skorunu natural language kadar iyi hesaplamaması ve yüzde elli eşiği gibi kolaylıklara sahip olmamasıdır.

### İki harflik terimler neden bulunamıyor?

InnoDB'de innodb_ft_min_token_size varsayılan olarak üçtür: daha kısa sözcükler indekse hiç girmez ve boolean modda sessizce sıfır sonuç döner. Kullanıcı için bu, aramanın bozuk olduğu anlamına gelir. Bu yüzden uygulama sorgudaki en uzun sözcük üç harften kısaysa LIKE yoluna düşer; yavaştır ama doğru cevap verir ve hangi modda çalıştığını ekranda söyler. Eşiği düşürmek mümkündür ama MySQL yapılandırmasına erişim ve indeksin yeniden oluşturulmasını gerektirir.

### Debounce varken yarış koruması gerçekten gerekli mi?

Evet, çünkü ikisi farklı sorunları çözer. Debounce istek sayısını azaltır ama ağ gecikmesinin eşit olmasını sağlamaz. "perf" isteği gider, kullanıcı yazmaya devam eder, "performans" isteği gider; ikinci istek kırk milisaniyede, birinci istek altı yüz milisaniyede dönerse ekranda "perf" sonuçları kalır. Kullanıcı doğru yazmıştır ama yanlış sonucu görür. Çözüm her isteğe artan bir numara vermek ve sunucunun onu aynen geri döndürmesidir; karar istemcide verilir çünkü "en son hangi isteği gönderdim" bilgisi yalnızca orada vardır.

### Türkçe arama neden ayrı bir çaba gerektiriyor?

İki ayrı yerde sorun çıkar. Bulmak MySQL'in işidir ve utf8mb4_unicode_ci karşılaştırması ü ile u, İ ile i katlamasını yapar. Vurgulamak ise PHP'nin işidir ve orada iki tuzak vardır: birincisi mb_strtolower büyük I harfini i yapar ama Türkçede I'nın küçüğü ı'dır; ikincisi ve daha sinsisi, mb_strtolower büyük İ harfini iki kod noktasına çevirir (i artı birleştirici nokta). Vurgulama konumları karakter indisine dayandığı için katlanmış metnin uzunluğu değiştiğinde bütün işaretler kayar. Bu yüzden birebir kendi eşleme tablomuzu kullanıyoruz.

### Vurgulama neden sunucuda yapılıyor ve sırası neden önemli?

Özet penceresi zaten sunucuda kesiliyor: dokuz yüz karakterlik bir gövdeden ilk eşleşmenin çevresini almak, hangi kelimelerin arandığını bilmeyi gerektirir. Sıra ise güvenlik meselesidir. Önce kaçışlayıp sonra mark etiketi eklemek HTML varlıklarını bozar: metinde ve işareti geçtiğinde kaçışlanmış hâli ampersand-amp-noktalı virgül olur ve kullanıcı "amp" aratırsa vurgulama tam o varlığın ortasına girer. Doğru sıra üç adımdır: ham metinde ayraçla işaretle, tamamını kaçışla, en son ayraçları etikete çevir. Böylece çıktıdaki tek HTML etiketi kodun koyduğu olur.

---

Kaynak: [Canlı Arama ve Otomatik Tamamlama](https://cilginyazilim.com/kutuphane/php-search-autocomplete)
