diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md
new file mode 100644
index 0000000..ba01f0d
--- /dev/null
+++ b/BEDIENUNGSANLEITUNG.md
@@ -0,0 +1,851 @@
+# mdgrep – Bedienungsanleitung
+
+**mdgrep** ist eine Volltextsuche für Markdown-Dateien und beliebige andere Texte. Sie versteht
+Wortbestandteile, Phrasen, reguläre Ausdrücke und logische Verknüpfungen mit Klammern – und lässt
+sich auf vier Arten nutzen:
+
+| Baustein | Wofür |
+|---|---|
+| **Kommandozeile** `mdgrep` | schnell im Terminal suchen, wie `grep` – aber blockweise und mit UND/ODER/NICHT |
+| **Python-Bibliothek** | Suche in eigene Programme einbauen, auch für Nicht-Markdown-Daten |
+| **Web-API** (Flask) | JSON-Suchschnittstelle für Webseiten und andere Dienste |
+| **Such-Widget** (JavaScript) | fertige Suchmaske mit Live-Ergebnissen für jede Webseite |
+
+Version: 0.1.2 · Lizenz: MIT · Repository: https://kitux.de/forgejo/dschlueter/mdgrep
+
+---
+
+## Inhaltsverzeichnis
+
+1. [Installation](#1-installation)
+2. [Suchsyntax](#2-suchsyntax)
+3. [Kommandozeile](#3-kommandozeile)
+4. [Python-Bibliothek](#4-python-bibliothek)
+5. [Web-API](#5-web-api)
+6. [Such-Widget](#6-such-widget)
+7. [Betrieb auf einem Server](#7-betrieb-auf-einem-server)
+8. [Voraussetzungen an die Dokumente](#8-voraussetzungen-an-die-dokumente)
+9. [Rezepte](#9-rezepte)
+10. [Fehlermeldungen](#10-fehlermeldungen)
+11. [Fehlersuche](#11-fehlersuche)
+12. [Entwicklung & neue Versionen](#12-entwicklung--neue-versionen)
+
+---
+
+## 1. Installation
+
+Voraussetzung: Python 3.10 oder neuer, `git` (für die Installation aus dem Repository).
+
+```bash
+# nur Bibliothek + Kommandozeile
+pip install "mdgrep @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.2"
+
+# zusätzlich Web-API (installiert Flask)
+pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.2"
+```
+
+In einer `requirements.txt`:
+
+```
+mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.2
+```
+
+Prüfen:
+
+```bash
+mdgrep --version # mdgrep 0.1.2
+python -c "import mdgrep; print(mdgrep.__version__)"
+```
+
+> **Hinweis:** mdgrep ist nicht auf PyPI veröffentlicht. Immer mit Git-URL und Versions-Tag
+> installieren – ein bloßes `pip install mdgrep` findet das Paket nicht (oder, falls jemand den
+> Namen dort später belegt, ein fremdes).
+
+---
+
+## 2. Suchsyntax
+
+Groß- und Kleinschreibung spielen **nie** eine Rolle (auch nicht bei Umlauten).
+
+| Eingabe | Bedeutung | Beispiel findet … |
+|---|---|---|
+| `rust` | Wortbestandteil | „Rust", „Rustacean", „trusted" |
+| `"open source"` | genaue Phrase; beliebiger Leerraum (auch Zeilenumbruch) zwischen den Wörtern | „Open Source", „open source" |
+| `/llms?\b/` | regulärer Ausdruck | „LLM", „LLMs" |
+| `a b` · `a AND b` · `a UND b` · `a & b` · `a && b` | beide Begriffe | |
+| `a OR b` · `a ODER b` · `a \| b` · `a \|\| b` | mindestens einer | |
+| `NOT a` · `NICHT a` · `-a` · `!a` | Begriff darf nicht vorkommen | |
+| `( … )` | Klammern | |
+
+**Vorrang:** NICHT bindet stärker als UND, UND stärker als ODER.
+`a OR b AND c` bedeutet also `a OR (b AND c)`. Im Zweifel Klammern setzen.
+
+**Hinweise:**
+
+- Operatoren werden nur in **Großbuchstaben** erkannt: `and` ist ein normales Suchwort, `AND` der Operator.
+- Wortbestandteile treffen auch mitten im Wort: `go` findet „**Go**ddard". Für **ganze Wörter**
+ einen regulären Ausdruck mit Wortgrenzen verwenden: `/\bgo\b/`.
+- Im regulären Ausdruck einen Schrägstrich als `\/` schreiben: `/tcp\/ip/`.
+- In Phrasen ein Anführungszeichen als `\"` schreiben.
+- Wörter mit Sonderzeichen funktionieren direkt: `AT&T`, `TCP/IP`, `e-mail`
+ (ein `-` oder `&` *innerhalb* eines Wortes ist kein Operator).
+
+**Beispiele:**
+
+```text
+(rust ODER go) UND NICHT crypto
+"machine learning" -/pytorch|tensorflow/
+llm (datenschutz OR privacy)
+/\bgpt-\d+\b/ NICHT "open source"
+```
+
+**Grenzen (Standard):** höchstens 500 Zeichen und 50 Suchbegriffe pro Anfrage
+(die Web-API begrenzt zusätzlich auf 300 Zeichen).
+
+---
+
+## 3. Kommandozeile
+
+```bash
+mdgrep SUCHANFRAGE [PFAD ...] [Optionen]
+```
+
+Ohne Pfad wird das aktuelle Verzeichnis durchsucht. Verzeichnisse werden rekursiv nach `*.md`
+durchsucht. Die Suchanfrage immer in **einfache Anführungszeichen** setzen, damit die Shell
+Klammern, `!`, `|` und `&` nicht selbst auswertet.
+
+### 3.1 Beispiele
+
+```bash
+mdgrep '(rust OR go) AND NOT crypto' notizen/ # alle *.md unter notizen/
+mdgrep '/\bllms?\b/' docs/*.md --split list # ein Treffer pro Listenpunkt
+mdgrep 'todo' . -l # nur Dateinamen
+mdgrep 'release' . -c # nur Anzahl der Treffer
+mdgrep '"open source"' . --json > treffer.json # JSON für andere Programme
+mdgrep 'fehler' logs/ -g '*.txt' # andere Dateien als Markdown
+mdgrep 'rust' . -m 5 # nach 5 Treffern aufhören
+```
+
+Ausgabe (Fundstellen farbig, wenn die Ausgabe ein Terminal ist):
+
+```text
+notizen/sprachen.md:5
+ Rust guarantees memory safety without a garbage collector. Its borrow checker
+ rejects data races at compile time.
+```
+
+### 3.2 Optionen
+
+| Option | Bedeutung | Standard |
+|---|---|---|
+| `-s`, `--split MODUS` | Einheit, die durchsucht und angezeigt wird (siehe 3.3) | `paragraph` |
+| `-g`, `--glob MUSTER` | Dateimuster in Verzeichnissen | `*.md` |
+| `-c`, `--count` | nur Anzahl der Treffer-Blöcke ausgeben | |
+| `-l`, `--files-with-matches` | nur Dateinamen ausgeben | |
+| `-j`, `--json` | JSON-Ausgabe (gleiches Format wie die Web-API) | |
+| `-C`, `--context N` | Zeichen Kontext um Treffer bei langen Blöcken | `80` |
+| `--max-chars N` | Blöcke über N Zeichen kürzen (`0` = nie) | `400` |
+| `-m`, `--max-count N` | nach N Treffern aufhören (`0` = alle) | `0` |
+| `--timeout SEK` | Zeitlimit für die ganze Suche | `30` |
+| `--color auto\|always\|never` | Farbe (`NO_COLOR` wird beachtet) | `auto` |
+| `-V`, `--version` | Version anzeigen | |
+
+### 3.3 Split-Modi – was ist ein Treffer?
+
+| Modus | Ein Block ist … | Geeignet für |
+|---|---|---|
+| `paragraph` | ein durch Leerzeilen getrennter Absatz | Fließtext, Notizen |
+| `list` | ein Listenpunkt der obersten Ebene (mit eingerückten Unterpunkten); Text außerhalb von Listen wird zu Absätzen | Aufzählungen, Berichte, Changelogs |
+| `heading` | ein Abschnitt von Überschrift zu Überschrift | Dokumentation, Wikis |
+| `file` | eine ganze Datei | „In welchen Dateien kommt X und Y vor?" |
+
+Die logische Verknüpfung gilt **pro Block**: `rust AND go` findet nur Blöcke, in denen beide
+Wörter vorkommen. Mit `--split file` gilt sie für die ganze Datei.
+
+### 3.4 Rückgabewerte (für Skripte)
+
+| Code | Bedeutung |
+|---|---|
+| `0` | Treffer gefunden |
+| `1` | kein Treffer |
+| `2` | Fehler: Syntaxfehler (mit Markierung der Position), Datei nicht lesbar oder Zeitlimit erreicht |
+
+```bash
+if mdgrep 'TODO OR FIXME' src/ -c > /dev/null; then echo "Es gibt noch offene Punkte"; fi
+```
+
+---
+
+## 4. Python-Bibliothek
+
+### 4.1 Nur die Suchsprache
+
+```python
+from mdgrep import compile_query, QuerySyntaxError
+
+q = compile_query('(rust OR go) AND NOT /crypto(currency)?/')
+q.matches("Rust 2.0 is out") # True
+q.spans("Rust and Go") # [(0, 4), (9, 11)] → Positionen zum Hervorheben
+
+try:
+ compile_query("(rust OR go")
+except QuerySyntaxError as e:
+ print(e.code, e.position, e.message) # unclosed_paren 0 Missing closing parenthesis
+```
+
+- `compile_query(text, max_length=500, max_terms=50)` → `Query`
+- `Query.matches(text, timeout=None)` → `bool`
+- `Query.spans(text, timeout=None)` → Liste von `(start, ende)`, sortiert und zusammengefasst;
+ Begriffe unter NICHT werden nicht markiert
+- Bei Überschreitung von `timeout` (Sekunden) wird `QueryTimeout` ausgelöst
+
+### 4.2 Markdown-Dateien durchsuchen
+
+```python
+from mdgrep import load_markdown, SearchEngine
+
+blocks = load_markdown("docs/", split="list", relative_to="docs/")
+engine = SearchEngine(blocks)
+
+result = engine.search("rust -crypto", limit=20)
+print(result.total_blocks, "Treffer")
+for group in result.groups:
+ b = group.block
+ print(f"{b.source}:{b.line}", "".join(s["t"] for s in group.segments))
+```
+
+`load_markdown(pfade, ...)`:
+
+| Parameter | Bedeutung | Standard |
+|---|---|---|
+| `paths` | Datei, Verzeichnis oder Liste davon | |
+| `split` | `file` · `heading` · `list` · `paragraph` | `paragraph` |
+| `meta` | Funktion `pfad → dict`; wird in die Metadaten jedes Blocks der Datei übernommen | |
+| `relative_to` | Verzeichnis, relativ zu dem `source`/`id` angegeben werden (wichtig für Web: keine Serverpfade preisgeben) | absolute/angegebene Pfade |
+| `pattern` | Dateimuster in Verzeichnissen | `*.md` |
+| `dialect` | `commonmark` oder `python-markdown` (siehe 4.6) | `commonmark` |
+| `encoding` | Zeichenkodierung | `utf-8` |
+
+Jeder Block bekommt in `meta` automatisch: `section` (nächste Überschrift darüber),
+`kind` (`file`/`section`/`item`/`paragraph`) und `markdown` (Original-Quelltext).
+Durchsucht wird der Text **ohne** Markdown-Zeichen (Links, Fett, Code …).
+
+### 4.3 Eigene Daten durchsuchen
+
+mdgrep ist nicht auf Markdown beschränkt. Jede Datenquelle lässt sich als Liste von `Block`-Objekten
+übergeben:
+
+```python
+from mdgrep import Block, SearchEngine
+
+tickets = [
+ Block(id="42", title="Ticket 42", text="Login schlägt mit SSO fehl", meta={"status": "offen"}),
+ Block(id="43", title="Ticket 43", text="Dunkelmodus-Schalter defekt", meta={"status": "erledigt"}),
+]
+engine = SearchEngine(tickets)
+engine.search("login ODER sso", filters={"status": "offen"})
+```
+
+| Feld | Bedeutung |
+|---|---|
+| `id` | eindeutige Kennung |
+| `title` | wird durchsucht und als Überschrift angezeigt (nicht im `text` wiederholen) |
+| `text` | wird durchsucht und als Ausschnitt angezeigt |
+| `anchor` | optionale Sprungmarke (`#anchor`), Linkziel im Widget, wenn kein `href` gesetzt ist |
+| `source`, `line` | Herkunft (Datei, Zeile ab 1) |
+| `meta` | beliebiges Wörterbuch; wird unverändert durchgereicht, Filter arbeiten darauf |
+
+### 4.4 Suchoptionen
+
+```python
+result = engine.search(
+ "rust",
+ filters={"lang": ["de", "en"]}, # alle Schlüssel müssen passen; leere Werte = egal
+ predicate=lambda b: b.line > 1, # zusätzlicher eigener Filter
+ group_by=lambda b: b.meta.get("url"), # gleiche Schlüssel → ein Ergebnis, Rest in group.also
+ sort_key=lambda b: b.meta["date"], reverse=True, # neueste zuerst
+ offset=0, limit=30, # Seiten (zählt Gruppen)
+ time_budget=1.5, # Sekunden; danach Teilergebnis mit truncated=True
+ snippet_chars=700, context=120, # lange Texte auf Fenster um die Treffer kürzen
+)
+```
+
+Das Ergebnis (`SearchResult`):
+
+| Attribut | Bedeutung |
+|---|---|
+| `groups` | Treffer der aktuellen Seite; je Gruppe `block`, `segments`, `title_segments`, `hits`, `also` |
+| `total_groups`, `total_blocks` | Anzahl Ergebnisse bzw. Fundstellen insgesamt |
+| `next_offset` | `offset` der nächsten Seite oder `None` |
+| `truncated` | `True`, wenn das Zeitbudget aufgebraucht wurde |
+| `elapsed_ms` | Suchdauer |
+| `to_dict()` | JSON-fähiges Wörterbuch (Format siehe 5.3) |
+
+`segments` ist eine Liste `[{"t": "Text", "hit": False}, {"t": "Treffer", "hit": True}, …]` –
+fertig zum Anzeigen mit Hervorhebung.
+
+### 4.5 Automatisch neu laden
+
+Ändern sich die Dateien, lädt die Suchmaschine sie selbst neu – ohne Neustart:
+
+```python
+from pathlib import Path
+
+def stand(verzeichnis=Path("docs")):
+ dateien = [p.stat() for p in verzeichnis.rglob("*.md")]
+ return len(dateien), max((s.st_mtime for s in dateien), default=0)
+
+engine = SearchEngine(lambda: load_markdown("docs", relative_to="docs"),
+ reload_check=stand, reload_interval=60)
+```
+
+`reload_check` liefert einen beliebigen vergleichbaren Wert; ändert er sich, wird neu geladen.
+Geprüft wird höchstens alle `reload_interval` Sekunden. `engine.reload()` lädt sofort neu.
+
+### 4.6 Markdown-Dialekt – wenn Treffer auf HTML-Elemente verlinken
+
+Wer Treffer mit Sprungmarken in gerenderten HTML-Seiten verknüpft, braucht dieselbe Aufteilung wie
+der Markdown-Renderer. Die Renderer unterscheiden sich bei Listen:
+
+| | `commonmark` (Standard) | `python-markdown` |
+|---|---|---|
+| Liste direkt unter einer Absatzzeile (ohne Leerzeile) | beginnt eine Liste | gehört zum Absatz |
+| Unterpunkt mit 1–3 Leerzeichen Einrückung | verschachtelt | eigener Punkt auf oberster Ebene |
+
+```python
+split_markdown(text, split="list", dialect="python-markdown")
+```
+
+### 4.7 Weitere Funktionen
+
+| Funktion | Zweck |
+|---|---|
+| `split_markdown(text, source=…, split=…, meta=…, dialect=…)` | einen Markdown-Text (statt Dateien) zerlegen |
+| `markdown_to_text(md)` | Markdown-Zeichen entfernen, Zeilen erhalten |
+| `segments(text, spans, max_chars=…, context=…)` | Text + Fundstellen → Anzeige-Segmente |
+| `export_static(zielverzeichnis)` | Widget-Dateien (JS + CSS) kopieren, siehe 6.4 |
+
+---
+
+## 5. Web-API
+
+Benötigt `mdgrep[web]`.
+
+### 5.1 Eigenständige App
+
+```python
+# app.py
+from mdgrep import SearchEngine, load_markdown
+from mdgrep.web import create_app
+
+engine = SearchEngine(lambda: load_markdown("docs", split="list", relative_to="docs"))
+app = create_app(engine, filters=["kind"]) # GET /search?q=…&kind=…
+```
+
+### 5.2 In eine bestehende Flask-App einhängen
+
+```python
+from mdgrep.web import create_blueprint
+
+app.register_blueprint(create_blueprint(engine, filters=["lang"]), url_prefix="/api")
+# → GET /api/search?q=… und Widget-Dateien unter /api/mdgrep-static/
+```
+
+Optionen von `create_blueprint()` (zusätzlich zu `group_by`, `sort_key`, `reverse`,
+`time_budget`, `snippet_chars`, `context` aus 4.4):
+
+| Option | Bedeutung | Standard |
+|---|---|---|
+| `filters` | Metadaten-Schlüssel, die als URL-Parameter filterbar sind | keine |
+| `default_limit` / `max_limit` | Ergebnisse pro Seite / Obergrenze | `30` / `100` |
+| `max_query_length` | maximale Länge der Suchanfrage | `300` |
+| `hide_meta` | Metadaten-Schlüssel, die nicht ausgeliefert werden | `("markdown",)` |
+| `name` | Blueprint-Name (bei mehreren Suchen in einer App) | `mdgrep` |
+
+`create_app(engine, url_prefix="", static_dir=None, **optionen)` – mit `static_dir` liefert die App
+zusätzlich die Dateien dieses Verzeichnisses unter `/` aus (praktisch zum Testen einer statischen Seite).
+
+### 5.3 Anfragen und Antworten
+
+```text
+GET /search?q=rust%20-crypto&lang=de&offset=0&limit=30
+```
+
+- `q` – Suchanfrage (leer → leeres Ergebnis)
+- je Name in `filters` ein Parameter: leer oder `all` = keine Einschränkung, `de,en` = mehrere Werte
+- `offset`, `limit` – Seiten
+
+Antwort (HTTP 200):
+
+```json
+{
+ "query": "rust -crypto", "total_groups": 12, "total_blocks": 15,
+ "offset": 0, "limit": 30, "next_offset": null, "truncated": false, "elapsed_ms": 3.2,
+ "results": [{
+ "block": {
+ "id": "a.md:4", "title": "…", "anchor": "", "source": "a.md", "line": 4, "meta": {},
+ "title_segments": [{"t": "…", "hit": false}],
+ "segments": [{"t": "The ", "hit": false}, {"t": "Rust", "hit": true}, {"t": " team …", "hit": false}],
+ "hits": 1
+ },
+ "also": [{"id": "b.md:9", "title": "…", "anchor": "", "source": "b.md", "line": 9, "meta": {}}]
+ }]
+}
+```
+
+Fehler (HTTP 400):
+
+```json
+{"error": {"code": "unclosed_paren", "message": "Missing closing parenthesis", "position": 0}}
+```
+
+### 5.4 Entwicklungsserver
+
+```bash
+python -m mdgrep.web --dir docs --split list --static site --port 8000
+# → http://127.0.0.1:8000/ (Dateien aus site/)
+# → http://127.0.0.1:8000/api/search (API)
+```
+
+| Option | Bedeutung | Standard |
+|---|---|---|
+| `--dir` | Verzeichnis mit Markdown-Dateien (Pflicht) | |
+| `--split` | Split-Modus | `paragraph` |
+| `--static` | dieses Verzeichnis unter `/` ausliefern; API dann unter `--api-prefix` | |
+| `--api-prefix` | Pfad der API, wenn `--static` gesetzt ist | `/api` |
+| `--filters` | kommagetrennte Metadaten-Schlüssel als Filter, z. B. `kind,section` | |
+| `--host`, `--port` | Adresse | `127.0.0.1`, `8000` |
+
+Neue oder geänderte Dateien werden im Entwicklungsserver nach spätestens 5 Sekunden übernommen.
+Ein vollständiges Beispiel liegt in `examples/`:
+
+```bash
+python -m mdgrep.web --dir examples/docs --split list --static examples/site
+```
+
+---
+
+## 6. Such-Widget
+
+Eine fertige Suchmaske: Ergebnisse erscheinen beim Tippen, veraltete Anfragen werden abgebrochen,
+die Suche steht in der URL (teilbar, Zurück-Taste funktioniert), Filter, „Mehr laden",
+aufklappbare Syntaxhilfe, Fehlermeldungen mit Position, Beschriftung Deutsch oder Englisch.
+Alle Texte werden sicher als Text eingefügt (kein HTML vom Server → kein Cross-Site-Scripting).
+Keine Abhängigkeiten, kein Framework.
+
+### 6.1 Einbinden
+
+```html
+
+
+
+
+```
+
+Ganz ohne eigenes JavaScript:
+
+```html
+
+```
+
+### 6.2 Optionen
+
+| Option | Bedeutung | Standard |
+|---|---|---|
+| `endpoint` | URL der Such-API (relativ oder absolut) | `search` |
+| `labels` | `'de'`, `'en'` oder Objekt mit einzelnen Texten (siehe 6.5) | `'en'` |
+| `filters` | Auswahlfelder `[{name, label, options:[{value,label}], value}]`; `name` = URL-Parameter der API | `[]` |
+| `live` | beim Tippen suchen | `true` |
+| `debounce` | Wartezeit nach dem letzten Tastendruck (ms) | `300` |
+| `minChars` | Mindestlänge der Eingabe | `2` |
+| `limit` | Ergebnisse pro Seite | `30` |
+| `syncUrl` | Suche und Filter in der Seiten-URL halten | `true` |
+| `autofocus` | Eingabefeld beim Laden fokussieren | `true` |
+| `help` | Syntaxhilfe anzeigen | `true` |
+| `hrefField` | Metadaten-Feld mit dem Linkziel | `href` |
+| `href(block)` | eigene Funktion für das Linkziel (hat Vorrang) | |
+| `dateField` | Metadaten-Feld, das als Datum angezeigt wird | |
+| `badgeFields` | Metadaten-Felder, die als Plaketten angezeigt werden | `[]` |
+| `formatBadge(feld, wert, block)` | Text einer Plakette anpassen | |
+| `alsoText(block)` | Linktext in der Liste „auch in …" | Datum bzw. Titel |
+
+Ohne `href` und ohne `hrefField`-Wert verlinkt ein Treffer auf `#anchor` des Blocks.
+
+### 6.3 Steuern per JavaScript
+
+```javascript
+var suche = MdGrep.mount(element, optionen);
+suche.setQuery('rust -crypto'); // Suchbegriff setzen und suchen
+suche.search(); // erneut suchen
+suche.input; // das Eingabefeld
+
+element.addEventListener('mdgrep:results', function (e) {
+ console.log(e.detail.total_groups); // vollständige API-Antwort
+});
+```
+
+Ist die Seite mit `?q=…` aufgerufen worden, sucht das Widget sofort. So kann ein einfaches
+Formular auf einer anderen Seite zur Suchseite weiterleiten:
+
+```html
+
+```
+
+### 6.4 Statische Webseiten
+
+Liegt die Seite nicht in Flask, die Widget-Dateien einmalig (oder bei jedem Build) kopieren:
+
+```python
+import mdgrep
+mdgrep.export_static("site/static") # schreibt mdgrep-widget.js und mdgrep-widget.css
+```
+
+Die Dateien werden nur neu geschrieben, wenn sich ihr Inhalt geändert hat.
+
+### 6.5 Aussehen anpassen
+
+Farben und Rundungen über CSS-Variablen (an `:root` oder einem umgebenden Element):
+
+```css
+:root {
+ --mdgrep-text: #1a1d2e; /* Text */
+ --mdgrep-muted: #6b7394; /* Nebentexte */
+ --mdgrep-surface: #ffffff; /* Kästchen, Eingabefeld */
+ --mdgrep-border: #d5d8e3; /* Rahmen */
+ --mdgrep-accent: #4a7cff; /* Fokus, Hover */
+ --mdgrep-mark-bg: #ffe58a; /* Hervorhebung */
+ --mdgrep-mark-text: inherit;
+ --mdgrep-error: #c0392b; /* Fehlermeldungen */
+ --mdgrep-radius: 0.5rem;
+}
+```
+
+Für einen Dunkelmodus die Variablen auf die eigenen Farbvariablen abbilden, z. B.
+`--mdgrep-surface: var(--meine-flaeche);` – dann folgt das Widget dem Seitenthema automatisch.
+
+Einzelne Texte überschreiben:
+
+```javascript
+labels: { lang: 'de', none: 'Leider nichts gefunden.', more: 'Weitere Treffer' }
+```
+
+---
+
+## 7. Betrieb auf einem Server
+
+Der Flask-Entwicklungsserver ist nur zum Testen gedacht. Für den Dauerbetrieb:
+
+### 7.1 Mit gunicorn
+
+```bash
+pip install gunicorn
+gunicorn --workers 2 --bind 127.0.0.1:8790 app:app
+```
+
+Jeder Worker hält alle Blöcke im Speicher (Richtwert: ca. 90 MB für 15 MB Markdown / 26 000 Blöcke).
+
+### 7.2 Als systemd-Dienst
+
+```ini
+# /etc/systemd/system/meine-suche.service
+[Unit]
+Description=Volltextsuche (mdgrep)
+After=network.target
+
+[Service]
+User=meinuser
+WorkingDirectory=/home/meinuser/projekt
+ExecStart=/home/meinuser/projekt/venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8790 app:app
+Restart=on-failure
+NoNewPrivileges=true
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target
+```
+
+```bash
+sudo systemctl daemon-reload && sudo systemctl enable --now meine-suche
+systemctl status meine-suche
+journalctl -u meine-suche -n 50
+```
+
+### 7.3 Hinter nginx mit Ratenbegrenzung
+
+```nginx
+# http-Kontext, z. B. /etc/nginx/conf.d/suche.conf
+limit_req_zone $binary_remote_addr zone=suche:10m rate=5r/s;
+
+# im server-Block
+location = /api/search {
+ limit_req zone=suche burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:8790/search$is_args$args;
+ proxy_read_timeout 10s;
+}
+```
+
+Das Widget zeigt bei HTTP 429 „Zu viele Anfragen – bitte einen Moment warten."
+
+### 7.4 Sicherheit
+
+- **Bösartige reguläre Ausdrücke** (z. B. `/(a|aa)+$/`) können exponentiell lange rechnen. mdgrep
+ begrenzt jede Suche auf `time_budget` (Web-API: 1,5 s) und liefert danach ein Teilergebnis
+ mit `truncated: true`.
+- **Ratenbegrenzung** im Reverse-Proxy (7.3) verhindert, dass viele Anfragen den Server auslasten.
+- **Keine Serverpfade** preisgeben: `load_markdown(..., relative_to=…)` verwenden.
+- **Interne Metadaten** mit `hide_meta` aus Antworten entfernen.
+- Das Widget fügt Ergebnisse nur als Text ein – auch Markdown mit eingebettetem HTML ist sicher.
+
+### 7.5 Leistung
+
+Ohne Index werden alle Blöcke bei jeder Suche durchlaufen. Richtwerte für 26 000 Blöcke (15 MB):
+einfache Begriffe und Phrasen 30–70 ms, reguläre Ausdrücke und Verknüpfungen 100–150 ms.
+Für deutlich größere Datenmengen (Hunderte MB) ist ein Index-basiertes Werkzeug besser geeignet.
+
+---
+
+## 8. Voraussetzungen an die Dokumente
+
+Welche Bedingungen eine Sammlung (z. B. Markdown-Dateien) erfüllen sollte – getrennt nach
+„sinnvoll durchsuchen" und „Treffer springen direkt an die Fundstelle".
+
+### 8.1 Für die Suche
+
+1. **Textdateien mit bekannter Kodierung** – Standard UTF-8; sonst `encoding=`. Andere Endungen
+ als `.md` über `-g '*.txt'` bzw. `pattern=`.
+
+2. **Größe passend zum Ansatz** – mdgrep arbeitet ohne Index: alle Blöcke liegen im Speicher und
+ werden bei jeder Suche durchlaufen. Bis etwa 50 MB Text unproblematisch (Richtwert: 15 MB →
+ 30–150 ms pro Suche); bei Hunderten MB ist ein Index-basiertes Werkzeug besser.
+
+3. **Gliederung, die zu einem Split-Modus passt** – die UND/ODER/NICHT-Logik gilt **pro Block**,
+ die Blockgröße bestimmt also die Trefferqualität:
+
+ | Aufbau der Dokumente | Split-Modus |
+ |---|---|
+ | Absätze, durch Leerzeilen getrennt | `paragraph` |
+ | Einträge als Listenpunkte (Berichte, Changelogs, Protokolle) | `list` |
+ | Abschnitte mit Überschriften (Dokumentation, Wiki) | `heading` |
+ | kurze Einzeldokumente | `file` |
+
+ Zu große Blöcke machen `a AND b` beliebig (in einer langen Datei steht fast alles), zu kleine
+ verfehlen Begriffe, die in benachbarten Sätzen stehen.
+
+4. **Ableitbare Metadaten** – für Filter (Sprache, Kategorie), Sortierung (Datum) und Gruppierung
+ (gleicher Eintrag in mehreren Dateien) braucht jeder Block Werte in `meta`:
+ - am einfachsten über ein **einheitliches Datei- oder Pfad-Schema**
+ (z. B. `2026_10_12_bericht_de.md`) und `meta=lambda pfad: {…}`;
+ - YAML-Frontmatter wird **nicht** ausgewertet, sondern als Text durchsucht – dafür einen kleinen
+ Adapter schreiben, der Frontmatter liest und `Block`-Objekte erzeugt (siehe 4.3);
+ - zum Zusammenfassen von Duplikaten (`group_by`) einen **stabilen Schlüssel** pro Eintrag
+ (URL, ID).
+
+5. **Grenzen einplanen**
+ - **Keine Wortstammsuche:** „Haus" findet nicht „Häuser" → `/h(au|äu)s/` oder `haus ODER häuser`.
+ - **Keine Relevanz-Sortierung:** die Reihenfolge bestimmt `sort_key` (z. B. Datum).
+ - Markdown-Zeichen (Links, Fett, Code) werden vor der Suche entfernt; **direkt eingebettetes
+ HTML** (`
`) und Tabellen-Striche bleiben als Text stehen.
+
+### 8.2 Für Sprungmarken
+
+Damit ein Treffer direkt an die Fundstelle einer gerenderten Seite springt, müssen **zwei Seiten
+exakt zusammenpassen**: die Blöcke von mdgrep und die IDs im HTML.
+
+1. **Gerenderte Seiten mit vorhersagbarer URL** – aus jeder Quelldatei muss sich ihr HTML-Pfad
+ berechnen lassen (`docs/a/b.md` → `site/a/b.html`). Diesen Pfad mit Sprungmarke in
+ `meta["href"]` schreiben (das Widget verlinkt darauf, siehe 6.2).
+
+2. **Jeder Block hat eine ID, die im HTML existiert** – drei Wege:
+
+ | Weg | Voraussetzung | Bewertung |
+ |---|---|---|
+ | **Überschriften** (`split="heading"`) | Der Renderer erzeugt Überschriften-IDs mit **demselben** Verfahren wie mdgrep (`slugify`, GitHub-Stil) | wenig Aufwand, aber prüfen: Python-Markdown (`toc`), MkDocs, Hugo und GitHub behandeln Umlaute, Sonderzeichen und doppelte Überschriften unterschiedlich |
+ | **Listenpunkte/Absätze** (`split="list"` / `paragraph`) | Eigenes Nachbearbeitungs-Skript nummeriert die HTML-Elemente nach **derselben Reihenfolge-Regel** wie die Blöcke und setzt `block.anchor` entsprechend | mittlerer Aufwand, robust |
+ | **Text Fragments** (`seite.html#:~:text=…`) | Aktueller Browser; keine Änderung am HTML. Im Widget über die Option `href(block)` bauen | kein Aufwand, aber unzuverlässig bei mehrdeutigem oder umformatiertem Text |
+
+ > **Bekannte Einschränkung:** `slugify` hängt bei **doppelten** Überschriften kein `-1`, `-2` an
+ > (anders als GitHub). Kommen Überschriften mehrfach vor, springen Treffer zur ersten.
+
+3. **Gleicher Markdown-Dialekt wie der Renderer** – Renderer zerlegen Listen unterschiedlich
+ (siehe 4.6). Passt `dialect` nicht, verschiebt sich die Nummerierung und Links zeigen auf falsche
+ Absätze.
+
+4. **Quelle und HTML auf demselben Stand** – nach jeder Änderung einer Quelldatei deren Seite neu
+ erzeugen; ändert sich die Zählregel, **alle** Seiten.
+
+5. **Einmal vollständig prüfen** – für jeden Block muss die Sprungmarke im HTML existieren **und**
+ auf ein Element zeigen, das seinen Text enthält. Skizze:
+
+ ```python
+ import re
+ from bs4 import BeautifulSoup
+
+ def pruefe(blocks, html_von): # html_von(block) → gerendertes HTML der Seite
+ fehler = []
+ for b in blocks:
+ el = BeautifulSoup(html_von(b), "html.parser").find(id=b.anchor)
+ probe = re.sub(r"\s+", " ", (b.title or b.text))[:40].lower()
+ if el is None or probe not in re.sub(r"\s+", " ", el.get_text()).lower():
+ fehler.append((b.source, b.anchor))
+ return fehler
+ ```
+
+ Das deckt Formatvarianten älterer Dokumente auf, die man sonst übersieht.
+
+### 8.3 Checkliste
+
+| | Bedingung |
+|---|---|
+| ☐ | Textdateien, bekannte Kodierung, insgesamt höchstens im zweistelligen MB-Bereich |
+| ☐ | Gliederung passt zu einem Split-Modus; Blöcke sind sinnvolle Einheiten |
+| ☐ | Metadaten über einheitliches Datei-Namensschema (oder Adapter) |
+| ☐ | stabiler Schlüssel pro Eintrag, falls Duplikate zusammengefasst werden |
+| ☐ | *Sprungmarken:* HTML-Pfad aus Quellpfad berechenbar |
+| ☐ | *Sprungmarken:* IDs im HTML nach derselben Regel wie die Blöcke, gleicher Dialekt |
+| ☐ | *Sprungmarken:* HTML nach jeder Änderung neu erzeugt; Konsistenz einmal geprüft |
+
+---
+
+## 9. Rezepte
+
+**Notizen nach zwei Begriffen im selben Absatz durchsuchen**
+
+```bash
+mdgrep 'kubernetes "persistent volume"' ~/notizen
+```
+
+**Dateien finden, die A enthalten, aber nicht B**
+
+```bash
+mdgrep 'docker -podman' ~/wiki --split file -l
+```
+
+**Changelog: alle Einträge zu Sicherheitslücken**
+
+```bash
+mdgrep '/\bCVE-\d{4}-\d+\b/ OR security' CHANGELOG.md --split list
+```
+
+**Treffer in einem Skript weiterverarbeiten**
+
+```bash
+mdgrep 'rust' docs --json | python -c "import json,sys; print([r['block']['source'] for r in json.load(sys.stdin)['results']])"
+```
+
+**Suche mit Sprachfilter aus dem Dateinamen** (`bericht_de.md`, `bericht_en.md`)
+
+```python
+engine = SearchEngine(load_markdown("berichte", split="list", relative_to="berichte",
+ meta=lambda p: {"lang": p.stem.rsplit("_", 1)[-1]}))
+engine.search("datenschutz", filters={"lang": "de"})
+```
+
+**Gleiche Einträge aus mehreren Dateien zusammenfassen, neueste zuerst**
+
+```python
+engine.search("rust", group_by=lambda b: b.meta.get("url"),
+ sort_key=lambda b: b.meta["date"], reverse=True)
+```
+
+**Praxisbeispiel:** Die Hacker-News-Berichte auf https://jamulix.de/HackerNews/search.html nutzen
+mdgrep mit einem kleinen Adapter (`hnews_search.py` im Projekt *hackernews*): Stories und Insights
+als Blöcke, Sprache/Datum/Linkziel als Metadaten, Gruppierung gleicher Stories, Sprungmarken in den
+HTML-Seiten (`dialect="python-markdown"`).
+
+---
+
+## 10. Fehlermeldungen
+
+Ungültige Anfragen liefern einen festen Code (für eigene Übersetzungen), eine Meldung und die
+Position (0-basiert; das Widget zeigt sie 1-basiert an).
+
+| Code | Bedeutung | Beispiel |
+|---|---|---|
+| `empty_query` | leere Anfrage | ` ` |
+| `empty_phrase` | leere Phrase | `""` |
+| `unterminated_phrase` | schließendes Anführungszeichen fehlt | `"open source` |
+| `unterminated_regex` | schließender Schrägstrich fehlt | `/abc` |
+| `invalid_regex` | regulärer Ausdruck ungültig | `/a(b/` |
+| `missing_operand` | Suchbegriff erwartet | `rust OR` · `AND go` |
+| `unexpected_paren` | schließende Klammer ohne öffnende | `rust)` |
+| `unexpected_token` | Operator an unerwarteter Stelle | |
+| `unclosed_paren` | schließende Klammer fehlt | `(rust OR go` |
+| `empty_group` | leere Klammern | `()` |
+| `too_long` | Anfrage zu lang | |
+| `too_many_terms` | zu viele Suchbegriffe | |
+
+Auf der Kommandozeile wird die Stelle markiert:
+
+```text
+mdgrep: Missing closing parenthesis
+ (rust OR go
+ ^
+```
+
+---
+
+## 11. Fehlersuche
+
+| Problem | Ursache / Lösung |
+|---|---|
+| `bash: !…: event not found` | Suchanfrage in **einfache** Anführungszeichen setzen: `mdgrep 'rust !crypto'` |
+| `go` findet zu viel | Wortbestandteile treffen auch in „Goddard" – ganze Wörter mit `/\bgo\b/` |
+| `and`/`or` wirken nicht als Operator | Operatoren nur in Großbuchstaben: `AND`, `OR`, `NOT` (oder `UND`, `ODER`, `NICHT`) |
+| Keine Treffer in `.txt`-Dateien | Standardmuster ist `*.md` → `-g '*.txt'` bzw. `pattern="*.txt"` |
+| `ModuleNotFoundError: flask` | Web-Teil braucht `pip install "mdgrep[web] @ git+…"` |
+| Widget zeigt „Die Suche ist fehlgeschlagen" | API nicht erreichbar: `endpoint` prüfen, Browser-Konsole/Netzwerk ansehen |
+| Widget zeigt „Zu viele Anfragen" | Ratenbegrenzung des Proxys greift (HTTP 429) – kurz warten oder `burst` erhöhen |
+| „Zeitlimit erreicht" | sehr aufwendiger regulärer Ausdruck; vereinfachen oder `time_budget` erhöhen |
+| Neue Dateien werden nicht gefunden | `reload_check` gesetzt? Geprüft wird nur alle `reload_interval` Sekunden |
+| Sprungmarken zeigen auf falsche Stellen | gleichen Markdown-`dialect` wie der HTML-Renderer verwenden (4.6) |
+| Widget-Darstellung „verbogen" | Seiten-CSS überschreibt Elemente (z. B. `details`, `ol`, `button`); gezielt unter `.mdgrep …` zurücksetzen |
+
+---
+
+## 12. Entwicklung & neue Versionen
+
+```bash
+git clone https://kitux.de/forgejo/dschlueter/mdgrep.git && cd mdgrep
+python -m venv .venv && .venv/bin/pip install -e ".[dev]"
+.venv/bin/pytest # alle Tests
+```
+
+Aufbau:
+
+```
+src/mdgrep/
+├── query.py # Suchsprache: Tokenizer, Parser, Auswertung, Fundstellen
+├── engine.py # Block, SearchEngine, SearchResult
+├── snippets.py # Anzeige-Segmente, Kürzen um Treffer
+├── markdown.py # Markdown → Blöcke, Markdown → Text
+├── cli.py # Kommandozeile
+├── web.py # Flask-Blueprint/-App, Entwicklungsserver
+└── static/ # mdgrep-widget.js, mdgrep-widget.css
+```
+
+Neue Version veröffentlichen:
+
+1. Version in `pyproject.toml` **und** `src/mdgrep/__init__.py` erhöhen (bei Widget-Änderungen auch im Kopf von `mdgrep-widget.js`)
+2. `CHANGELOG.md`: Abschnitt `[Unreleased]` in die neue Version umbenennen (mit Datum), „Beim Update zu tun"
+ ergänzen; bei Änderungen am inneren Aufbau auch `FUNKTIONSWEISE.md` aktualisieren
+3. Tests laufen lassen: `.venv/bin/pytest`
+4. Commit, Tag und Push: `git tag -a v0.1.2 -m "mdgrep 0.1.2" && git push origin main --tags`
+5. In den nutzenden Projekten den Tag in `requirements.txt` anpassen und `pip install -r requirements.txt`
+ ausführen; laufende Dienste neu starten.
+
+Versionsregel: Änderungen an öffentlichen Funktionen, am JSON-Format oder an Widget-Optionen, die
+bestehende Nutzer brechen, nur mit erhöhter Nebenversion (0.**2**.0) und Hinweis im Commit.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d929804
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,58 @@
+# Änderungsprotokoll
+
+Alle nennenswerten Änderungen an mdgrep. Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
+Versionen nach [Semantic Versioning](https://semver.org/lang/de/) (vor 1.0: Nebenversion = möglicher Bruch).
+
+Jede Version ist als Git-Tag verfügbar:
+`pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@vX.Y.Z"`
+
+## [Unreleased]
+
+## [0.1.2] – 2026-10-15
+
+### Dokumentation
+- `BEDIENUNGSANLEITUNG.md` (deutsch): Installation, Suchsyntax, Kommandozeile, Python-API, Web-API, Widget,
+ Serverbetrieb, Voraussetzungen an die Dokumente, Rezepte, Fehlercodes, Fehlersuche, Release-Ablauf.
+- `FUNKTIONSWEISE.md`: interner Aufbau, Datenfluss und Designentscheidungen.
+- `CHANGELOG.md` (diese Datei).
+
+### Beim Update zu tun
+- Nichts; nur Dokumentation, Code unverändert und voll kompatibel zu 0.1.1. Widget-Dateien unverändert
+ (Kopfzeile meldet weiterhin v0.1.0).
+
+## [0.1.1] – 2026-10-12
+
+### Neu
+- `split_markdown()` / `load_markdown()`: Parameter `dialect="commonmark" | "python-markdown"`, damit Listen-Blöcke
+ zur HTML-Struktur des verwendeten Renderers passen (Python-Markdown: Liste braucht Leerzeile davor,
+ Verschachtelung erst ab 4 Leerzeichen).
+
+### Geändert
+- Deutlich schneller: der durchsuchte Text (Titel + Text) wird beim Laden einmal vorberechnet; das
+ Regex-Zeitlimit gilt nur noch für `/regex/`-Terme (Wörter und Phrasen laufen linear). Typische Suchen über
+ 26 000 Blöcke: ca. 30–140 ms statt 250–700 ms.
+
+### Beim Update zu tun
+- Nichts; voll kompatibel zu 0.1.0. Widget-Dateien unverändert (Kopfzeile meldet weiterhin v0.1.0).
+
+## [0.1.0] – 2026-10-12
+
+Erstveröffentlichung.
+
+### Neu
+- Suchsprache: Wortbestandteile, `"Phrasen"`, `/reguläre Ausdrücke/`, `AND`/`OR`/`NOT` (auch `UND`/`ODER`/
+ `NICHT`, `&`, `|`, `-`, `!`) mit Klammern; Groß-/Kleinschreibung egal; Fehler mit festem Code und Position;
+ Schutz vor katastrophalen regulären Ausdrücken durch Zeitlimits.
+- `SearchEngine` über Blöcke im Speicher: Filter, eigene Prädikate, Gruppierung, Sortierung, Seiten,
+ Zeitbudget, automatisches Neuladen, Ausschnitte mit Hervorhebung.
+- Markdown-Zerleger mit den Modi `file`, `heading`, `list`, `paragraph`; Markdown → Text.
+- Kommandozeile `mdgrep` (grep-artig, JSON-Ausgabe, grep-Rückgabewerte).
+- Flask-Blueprint/-App mit JSON-API und Auslieferung der Widget-Dateien; Entwicklungsserver
+ `python -m mdgrep.web`.
+- JavaScript-Such-Widget ohne Framework: Live-Suche, URL-Zustand, Filter, „Mehr laden", Syntaxhilfe,
+ deutsch/englisch, Gestaltung über CSS-Variablen; `export_static()` für statische Seiten.
+
+[Unreleased]: https://kitux.de/forgejo/dschlueter/mdgrep/compare/v0.1.2...main
+[0.1.2]: https://kitux.de/forgejo/dschlueter/mdgrep/compare/v0.1.1...v0.1.2
+[0.1.1]: https://kitux.de/forgejo/dschlueter/mdgrep/compare/v0.1.0...v0.1.1
+[0.1.0]: https://kitux.de/forgejo/dschlueter/mdgrep/src/tag/v0.1.0
diff --git a/FUNKTIONSWEISE.md b/FUNKTIONSWEISE.md
new file mode 100644
index 0000000..db3cce5
--- /dev/null
+++ b/FUNKTIONSWEISE.md
@@ -0,0 +1,339 @@
+# mdgrep – Funktionsweise
+
+Dieses Dokument erklärt, **wie mdgrep innen arbeitet und warum es so gebaut ist**. Für die Benutzung siehe
+[BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md), für Änderungen je Version [CHANGELOG.md](CHANGELOG.md).
+
+Stand: Version 0.1.2
+
+---
+
+## Inhaltsverzeichnis
+
+1. [Überblick](#1-überblick)
+2. [Module](#2-module)
+3. [Suchsprache: vom Text zum Syntaxbaum](#3-suchsprache-vom-text-zum-syntaxbaum)
+4. [Auswertung und Zeitlimit](#4-auswertung-und-zeitlimit)
+5. [Hervorhebung und Ausschnitte](#5-hervorhebung-und-ausschnitte)
+6. [Markdown-Zerlegung](#6-markdown-zerlegung)
+7. [Suchmaschine](#7-suchmaschine)
+8. [Web-API und Widget](#8-web-api-und-widget)
+9. [Designentscheidungen](#9-designentscheidungen)
+10. [Grenzen und Erweiterungsideen](#10-grenzen-und-erweiterungsideen)
+
+---
+
+## 1. Überblick
+
+mdgrep hat **keinen Suchindex**. Alle Texte liegen als *Blöcke* im Speicher; jede Suche prüft jeden Block
+(nach Filtern) gegen die übersetzte Suchanfrage. Das ist einfach, unterstützt beliebige reguläre Ausdrücke
+und ist bei einigen Megabyte Text schnell genug.
+
+```mermaid
+flowchart TB
+ subgraph Anfrage
+ direction TB
+ Q["Suchtext (rust OR go) -crypto"] --> T[Tokenizer]
+ T --> P[Parser]
+ P --> AST["Syntaxbaum And / Or / Not / Term"]
+ end
+ subgraph Daten
+ direction TB
+ D["Markdown-Dateien oder eigene Daten"] --> L["load_markdown() split_markdown()"]
+ L --> B["Blöcke title, text, meta"]
+ end
+ AST --> E["SearchEngine.search()"]
+ B --> E
+ E --> F["Filter"]
+ F --> M["Treffer prüfen (mit Zeitbudget)"]
+ M --> S["sortieren, gruppieren, Seite ausschneiden"]
+ S --> H["Fundstellen (Spans) → Ausschnitte (Segmente)"]
+ H --> R["SearchResult .to_dict()"]
+ R --> CLI["mdgrep (Terminal)"]
+ R --> API["Flask GET /search"]
+ API --> W["Widget (Browser)"]
+```
+
+Drei Begriffe tragen das ganze Design:
+
+| Begriff | Bedeutung |
+|---|---|
+| **Block** | kleinste durchsuchbare Einheit – ein Absatz, Listenpunkt, Abschnitt, eine Datei oder ein eigener Datensatz. Die logische Verknüpfung gilt *pro Block*. |
+| **Span** | Fundstelle als `(start, ende)` im Text eines Blocks |
+| **Segment** | Anzeigestück `{"t": Text, "hit": bool}`; eine Liste von Segmenten ergibt den hervorgehobenen Ausschnitt |
+
+---
+
+## 2. Module
+
+| Modul | Aufgabe | Wichtigste Namen |
+|---|---|---|
+| `query.py` | Suchsprache: Tokenizer, Parser, Auswertung, Fundstellen | `compile_query`, `Query`, `QuerySyntaxError`, `QueryTimeout` |
+| `engine.py` | Blöcke halten, filtern, prüfen, sortieren, gruppieren, Seiten | `Block`, `SearchEngine`, `SearchResult`, `Group` |
+| `snippets.py` | Fundstellen → Anzeige-Segmente, Kürzen um Treffer | `segments` |
+| `markdown.py` | Markdown → Blöcke; Markdown → Klartext | `load_markdown`, `split_markdown`, `markdown_to_text`, `slugify` |
+| `cli.py` | Kommandozeile | `main` (Einstieg `mdgrep`) |
+| `web.py` | Flask-Blueprint/-App, Entwicklungsserver | `create_blueprint`, `create_app` |
+| `static/` | Browser-Widget | `mdgrep-widget.js` (`MdGrep.mount`), `mdgrep-widget.css` |
+| `__init__.py` | öffentliche API, `export_static()` | |
+
+Abhängigkeiten zeigen nur in eine Richtung: `engine` nutzt `query` und `snippets`, `markdown` nutzt
+`engine` (für `Block`), `cli` und `web` nutzen alles. `query` und `snippets` hängen von nichts im Paket ab.
+Nur `web.py` braucht Flask; der Rest kommt mit dem Paket `regex` aus.
+
+---
+
+## 3. Suchsprache: vom Text zum Syntaxbaum
+
+### 3.1 Tokenizer (`_tokenize`)
+
+Der Suchtext wird Zeichen für Zeichen in Tokens zerlegt:
+
+| Zeichen am Token-Anfang | Token |
+|---|---|
+| Leerraum | wird übersprungen |
+| `(` `)` | Klammer |
+| `&` / `&&`, `\|` / `\|\|` | `AND` / `OR` |
+| `!` | `NOT` |
+| `-` **gefolgt von Nicht-Leerraum** | `NOT` (ein einzelnes `-` mit Leerzeichen danach ist ein normales Suchwort) |
+| `"` … `"` | Phrase; `\"` steht für ein Anführungszeichen |
+| `/` … `/` | regulärer Ausdruck; `\/` wird zu `/`, andere `\x` bleiben erhalten |
+| sonst | Wort bis zum nächsten Leerraum, `(`, `)` oder `"` |
+
+Ein Wort, das genau `AND`, `OR`, `NOT`, `UND`, `ODER` oder `NICHT` lautet, wird zum Operator – **nur in
+Großbuchstaben**. Weil `-`, `&`, `|`, `!` und `/` nur *am Token-Anfang* Sonderbedeutung haben, bleiben Wörter
+wie `e-mail`, `AT&T` oder `TCP/IP` normale Suchwörter.
+
+### 3.2 Parser (rekursiver Abstieg)
+
+```text
+query := or_expr EOF
+or_expr := and_expr ( OR and_expr )*
+and_expr := not_expr ( [AND] not_expr )* ← AND darf fehlen (Leerzeichen = UND)
+not_expr := NOT not_expr | primary
+primary := TERM | "(" or_expr ")"
+```
+
+Daraus ergibt sich der Vorrang **NOT > AND > OR**. Ein implizites UND wird erkannt, wenn nach einem Ausdruck
+direkt ein Term, eine öffnende Klammer oder ein NOT folgt.
+
+Fehler werden als `QuerySyntaxError(message, position, code)` gemeldet; `position` zeigt auf das Token (bzw. die
+öffnende Klammer oder das Textende), `code` ist stabil und übersetzbar (Liste in der Bedienungsanleitung).
+
+Nebenbei merkt sich der Parser für jeden Term, ob er unter einer **ungeraden Anzahl von NOT** steht
+(`negated`). Das braucht später die Hervorhebung: `rust -crypto` markiert nur „rust".
+
+### 3.3 Terme werden zu regulären Ausdrücken
+
+| Term | Muster | Beispiel |
+|---|---|---|
+| Wort | `regex.escape(wort)` | `c++` → `c\+\+` |
+| Phrase | Wörter maskiert, verbunden mit `\s+` | `"open source"` → `open\s+source` (trifft auch über Zeilenumbrüche) |
+| Regex | unverändert | `/llms?\b/` |
+
+Alle Muster werden mit `IGNORECASE | UNICODE` des PyPI-Moduls **`regex`** kompiliert (Umlaute inklusive).
+Ungültige reguläre Ausdrücke fallen schon hier als `invalid_regex` auf – nicht erst bei der Suche.
+
+---
+
+## 4. Auswertung und Zeitlimit
+
+Der Syntaxbaum besteht aus `Term`, `Not`, `And`, `Or`; jeder Knoten hat `evaluate(text, deadline)`.
+`And`/`Or` nutzen `all()`/`any()` und brechen daher früh ab.
+
+**Zeitlimit:** Bestimmte reguläre Ausdrücke brauchen exponentiell lange („katastrophales Backtracking",
+z. B. `/(a|aa)+$/`). Das Modul `regex` kann jede Suche mit `timeout=` abbrechen. Der Parameter ist aber teuer
+(etwa vierfache Laufzeit pro Aufruf), daher:
+
+- **Wörter und Phrasen** laufen **ohne** Zeitlimit – maskierte Literale und `\s+` sind linear, können also nicht
+ entarten.
+- **Reguläre Ausdrücke** bekommen als Zeitlimit die Restzeit bis zur Frist (`deadline`).
+- `Query.matches()` prüft die Frist **einmal pro Aufruf**; ist sie abgelaufen, folgt `QueryTimeout`.
+
+Die Suchmaschine setzt eine Frist für die ganze Suche (`time_budget`). Läuft sie ab, endet die Schleife und
+das Ergebnis trägt `truncated = True` – der Server bleibt erreichbar, der Nutzer bekommt ein Teilergebnis.
+
+---
+
+## 5. Hervorhebung und Ausschnitte
+
+**Fundstellen (`Query.spans`)**: Für jeden *positiven* Term (nicht unter NOT) werden alle Treffer gesucht,
+Treffer der Länge 0 (z. B. von `/x*/`) verworfen, dann sortiert und überlappende oder angrenzende Bereiche
+zusammengefasst. Aus `open OR source` auf „open source" werden so zwei getrennte Markierungen, aus
+`abc OR bcd` auf „abcd" eine durchgehende.
+
+**Ausschnitte (`snippets.segments`)**: Ist der Text kürzer als `max_chars`, wird er komplett in Segmente
+zerlegt. Sonst:
+
+1. Um jede Fundstelle wird ein Fenster von `context` Zeichen gelegt.
+2. Fensterränder werden an Wortgrenzen verschoben (höchstens 20 Zeichen weit), damit keine Wörter zerschnitten
+ werden.
+3. Überlappende Fenster verschmelzen.
+4. Fenster werden aufgenommen, solange das Budget `max_chars` reicht – das **erste Fenster immer**, damit
+ mindestens ein Treffer sichtbar ist.
+5. Lücken und abgeschnittene Enden werden durch `" … "` markiert.
+
+Ohne Fundstelle im Text (Treffer nur im Titel) wird der Textanfang gezeigt.
+
+---
+
+## 6. Markdown-Zerlegung
+
+`split_markdown()` arbeitet **zeilenbasiert** mit einem kleinen Scanner, nicht mit einem vollständigen
+Markdown-Parser. Der Scanner liefert je Zeile: Index, Inhalt, „in Code-Block?" und ggf. die erkannte
+Überschrift (`#` bis `######`). Zeilen innerhalb von Code-Blöcken (` ``` ` oder `~~~`) werden nie als
+Überschrift oder Listenpunkt gedeutet.
+
+| Modus | Regel |
+|---|---|
+| `file` | eine Datei = ein Block; Titel = erste Überschrift |
+| `heading` | neuer Block bei jeder Überschrift; Titel = Überschrift, Anker = `slugify(Überschrift)` |
+| `paragraph` | neuer Block nach jeder Leerzeile (außerhalb von Code-Blöcken); Trennlinien werden übersprungen |
+| `list` | neuer Block bei jedem Listenpunkt der obersten Ebene; eingerückte Zeilen und Zeilen direkt darunter („lazy continuation") gehören dazu; Text außerhalb von Listen wird zu Absatz-Blöcken; Trennlinien und Überschriften beenden einen Block |
+
+**Dialekte** (nur Modus `list`): Markdown-Renderer zerlegen Listen unterschiedlich. Damit Blöcke zu den
+Elementen im gerenderten HTML passen (wichtig für Sprungmarken), gibt es zwei Regelsätze:
+
+| | `commonmark` | `python-markdown` |
+|---|---|---|
+| Listenmarker | `-` `*` `+` `1.` `1)` am Zeilenanfang | dazu bis zu 3 Leerzeichen Einrückung; nummeriert nur `1.` |
+| Liste direkt unter einer Absatzzeile | beginnt eine Liste | gehört zum Absatz |
+
+Jeder Block bekommt `meta["section"]` (letzte Überschrift darüber), `meta["kind"]` und `meta["markdown"]`
+(Originaltext), dazu `source`, `line` und `id = ":"`.
+
+**Markdown → Klartext** (`markdown_to_text`): pro Zeile werden Listen-, Zitat- und Überschriftenmarker entfernt,
+Bilder durch ihren Alternativtext, Links durch ihren Linktext ersetzt, Inline-Code, Fett, Kursiv und
+Durchgestrichen ausgepackt (Unterstriche nur an Wortgrenzen, damit `snake_case` erhalten bleibt). Zeilen
+bleiben erhalten, mehrere Leerzeilen werden zu einer. Durchsucht und angezeigt wird dieser Klartext.
+
+In den Modi `file` und `heading` wird der Titel vom Textanfang entfernt, damit er im Ergebnis nicht doppelt
+erscheint.
+
+---
+
+## 7. Suchmaschine
+
+### 7.1 Laden
+
+`SearchEngine(quelle, reload_check=…, reload_interval=…)` nimmt eine Liste von Blöcken oder eine Funktion, die
+sie liefert. Beim Laden wird für jeden Block der **Suchtext** `titel + "\n" + text` einmal vorberechnet –
+Titel und Text werden gemeinsam durchsucht, aber getrennt angezeigt.
+
+**Neuladen:** Mit Ladefunktion und `reload_check` (liefert einen beliebigen vergleichbaren Wert, etwa
+Dateianzahl und neueste Änderungszeit) prüft die Suchmaschine höchstens alle `reload_interval` Sekunden, ob
+sich der Wert geändert hat, und lädt dann neu. Blöcke und Suchtexte werden zusammen als ein Tupel ersetzt –
+laufende Suchen sehen immer einen konsistenten Stand.
+
+### 7.2 Ablauf von `search()`
+
+```mermaid
+flowchart TD
+ A["Anfrage kompilieren (oder fertige Query)"] --> B["Frist = jetzt + time_budget"]
+ B --> C["ggf. neu laden"]
+ C --> D{"für jeden Block"}
+ D -->|"Filter / Prädikat passen nicht"| D
+ D -->|passt| E{"query.matches()"}
+ E -->|ja| F[in Trefferliste]
+ E -->|nein| D
+ E -->|QueryTimeout| G["truncated = True, Schleife beenden"]
+ F --> D
+ D -->|fertig| H["sortieren (sort_key, reverse)"]
+ G --> H
+ H --> I["gruppieren (group_by) Reihenfolge = erster Treffer je Gruppe"]
+ I --> J["Seite: Gruppen offset … offset+limit"]
+ J --> K["nur für die Seite: Spans + Segmente des ersten Blocks je Gruppe"]
+ K --> L[SearchResult]
+```
+
+Wichtige Details:
+
+- **Filter** vergleichen `block.meta[schlüssel]` mit einem Wert oder einer Menge von Werten; leere Werte werden
+ ignoriert.
+- **Gruppierung**: `group_by(block)` liefert einen Schlüssel oder `None` (= eigene Gruppe). Weil vorher sortiert
+ wird, ist der erste Block einer Gruppe der „beste" (z. B. neueste); die übrigen landen in `also`.
+- **Seiten** zählen Gruppen, nicht Blöcke. `next_offset` ist `None`, wenn keine weitere Seite existiert.
+- **Teure Arbeit nur für die sichtbare Seite**: Fundstellen und Ausschnitte werden nur für die bis zu
+ `limit` angezeigten Gruppen berechnet, nicht für alle Treffer.
+
+---
+
+## 8. Web-API und Widget
+
+### 8.1 Web-API (`web.py`)
+
+`create_blueprint()` registriert `GET /search` und liefert die Widget-Dateien unter `/mdgrep-static/` aus.
+Ablauf einer Anfrage: Parameter lesen und begrenzen (`offset ≥ 0`, `1 ≤ limit ≤ max_limit`, Länge der
+Anfrage) → Filter-Parameter übernehmen (leer/`all` = keine Einschränkung, `a,b` = mehrere Werte) →
+`engine.search()` → interne Metadaten (`hide_meta`, Standard `markdown`) entfernen → JSON.
+`QuerySyntaxError` wird zu HTTP 400 mit `{"error": {code, message, position}}`.
+
+`create_app()` baut eine eigenständige Flask-App (Unicode im JSON nicht maskiert) und kann zusätzlich ein
+Verzeichnis statisch ausliefern; `send_from_directory` verhindert dabei Zugriffe außerhalb dieses Verzeichnisses.
+
+### 8.2 Widget (`static/mdgrep-widget.js`)
+
+Ein einzelnes Skript ohne Abhängigkeiten, das `window.MdGrep = {mount, labels, version}` bereitstellt.
+
+```mermaid
+sequenceDiagram
+ participant U as Nutzer
+ participant W as Widget
+ participant A as API
+ U->>W: tippt „rust -cr"
+ Note over W: Debounce 300 ms – nichts senden
+ U->>W: tippt „ypto"
+ W->>W: URL aktualisieren (?q=…)
+ W->>A: GET search?q=rust -crypto (AbortController #1)
+ U->>W: ändert Filter „Sprache"
+ W->>A: Anfrage #1 abbrechen, GET … &lang=de (#2)
+ A-->>W: JSON #2
+ W->>W: Kästchen per createElement/textContent bauen
+ W-->>U: Ergebnisse, Statuszeile
+```
+
+- **Zustand in der URL** (`history.replaceState`): Suchbegriff und Filter stehen in `?q=…&lang=…`; beim Laden
+ liest das Widget sie wieder ein und sucht sofort. Dadurch sind Suchen teilbar, und die Zurück-Taste aus einem
+ geöffneten Treffer stellt die Suche wieder her.
+- **Veraltete Antworten** werden doppelt abgefangen: `AbortController` bricht die alte Anfrage ab, und eine
+ Antwort wird nur angezeigt, wenn sie zur zuletzt gestarteten Anfrage gehört.
+- **Sicherheit:** Alle Texte aus der Antwort werden mit `textContent` bzw. als Textknoten eingefügt, ``
+ entsteht per `createElement`. HTML aus Dokumenten wird so nie ausgeführt.
+- **Fehler:** HTTP 400 → übersetzte Meldung zum `code` plus Position; 429 → „zu viele Anfragen"; sonst
+ allgemeine Meldung.
+- **Gestaltung:** nur Klassen `mdgrep-*` und CSS-Variablen `--mdgrep-*` mit Standardwerten als Fallback;
+ `[hidden]` wird mit `!important` erzwungen, damit Seiten-CSS ausgeblendete Elemente nicht wieder einblendet.
+- **Ohne Skriptcode:** Elemente mit `data-mdgrep` werden beim Laden automatisch eingerichtet.
+
+---
+
+## 9. Designentscheidungen
+
+| Entscheidung | Verworfene Alternativen | Grund |
+|---|---|---|
+| **Kein Index – Volltext-Scan im Speicher** | Whoosh, SQLite FTS, Elasticsearch | Reguläre Ausdrücke lassen sich ohnehin nicht über einen Wortindex beantworten; Whoosh ist seit 2016 ungepflegt; bei einigen MB Text ist der Scan schnell genug (≈ 30–150 ms bei 15 MB) und es gibt nichts zu synchronisieren. |
+| **PyPI-`regex` statt `re`** | Standardmodul `re` | Nur `regex` kann eine laufende Suche per `timeout=` abbrechen – Voraussetzung, um Nutzer-Regex auf einem Server zu erlauben. |
+| **Zeitlimit nur für Regex-Terme** | Zeitlimit für alle Terme | Der Timeout-Parameter vervierfacht die Kosten pro Aufruf; Wörter und Phrasen können nicht entarten. Zusammen mit den vorberechneten Suchtexten gemessen 4–9× schnellere Suchen (Version 0.1.1). |
+| **Logik pro Block** | pro Datei, pro Satz | Datei ist zu grob (`a AND b` trifft fast immer), Satz zu fein; der Split-Modus lässt den Nutzer die passende Einheit wählen. |
+| **Titel und Text getrennt** | Titel als erste Textzeile | Sonst erscheint der Titel im Ergebnis doppelt; Programme mit eigenen Daten können einen echten Titel setzen. |
+| **Zeilenbasierter Markdown-Zerleger** | vollständiger Markdown-Parser (z. B. markdown-it) | Für Blockgrenzen genügt ein kleiner Scanner, keine zusätzliche Abhängigkeit; Abweichungen einzelner Renderer deckt die `dialect`-Option ab. |
+| **`dialect`-Option** | eine feste Regel | Sprungmarken funktionieren nur, wenn Blöcke exakt den HTML-Elementen des Renderers entsprechen; CommonMark und Python-Markdown unterscheiden sich bei Listen messbar (im hnews-Archiv zwei Fehlerbilder in 70 bzw. 197 Dateien). |
+| **`relative_to`** | absolute Pfade | Eine Web-API soll keine Serverpfade preisgeben. |
+| **Stabile Fehlercodes** | nur Fehlermeldungstext | Oberflächen können Meldungen übersetzen (Widget: de/en), ohne Texte zu parsen. |
+| **Widget ohne Framework, nur `textContent`** | React/Vue, `innerHTML` | Läuft in jeder Seite, keine Build-Kette, keine XSS-Lücke durch Dokumentinhalte. |
+| **Eigenes Repository, Installation per Git-Tag** | PyPI, Unterordner eines Projekts | Wiederverwendbar in beliebigen Projekten, unabhängig versioniert, ohne Veröffentlichung auf PyPI. |
+| **MIT-Lizenz** | GPL | Einfache Wiederverwendung, auch in fremden Projekten. |
+
+---
+
+## 10. Grenzen und Erweiterungsideen
+
+| Grenze | Auswirkung | Mögliche Erweiterung |
+|---|---|---|
+| Linearer Scan | Laufzeit wächst mit der Textmenge; bei Hunderten MB zu langsam | Vorfilter mit Trigramm-Index für Wörter/Phrasen |
+| Jeder Server-Worker hält alle Blöcke | ca. 90 MB pro Worker bei 15 MB Markdown | gemeinsamer Speicher oder Index auf Platte |
+| Keine Wortstämme | „Haus" findet nicht „Häuser" | optionale Stemming-Stufe (z. B. Snowball) |
+| Keine Relevanz-Sortierung | Reihenfolge nur nach `sort_key` | Score aus Trefferzahl/Position |
+| Doppelte Überschriften | `slugify` hängt kein `-1`, `-2` an (anders als GitHub) | Zähler je Datei wie GitHub |
+| Nur ATX-Überschriften (`#`) | Setext-Überschriften (unterstrichen mit `===`/`---`) werden nicht erkannt | Scanner erweitern |
+| Operatoren nur in Großbuchstaben | `and` ist ein Suchwort | bewusst so; ggf. Option |
+| Titel und Text gemeinsam durchsucht | eine Phrase kann theoretisch über die Grenze Titel/Text hinweg treffen | Grenzzeichen statt Zeilenumbruch |
diff --git a/README.md b/README.md
index 7b43248..75fe670 100644
--- a/README.md
+++ b/README.md
@@ -9,11 +9,20 @@ Boolean full-text search over Markdown (and any other text) – as a **Python li
- protection against catastrophic regular expressions (time limit per search)
- no search index: blocks live in memory, a few MB of text are searched in milliseconds
+## Documentation
+
+| Document | Content |
+|---|---|
+| [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md) | User manual (German): installation, syntax, CLI, API, widget, operation, troubleshooting |
+| [FUNKTIONSWEISE.md](FUNKTIONSWEISE.md) | How it works (German): architecture, data flow, design decisions, limits |
+| [CHANGELOG.md](CHANGELOG.md) | Changes per version and what to do when updating |
+| [LICENSE](LICENSE) | MIT |
+
## Installation
```bash
-pip install "mdgrep @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.0" # library + CLI
-pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.0" # + Flask API
+pip install "mdgrep @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.2" # library + CLI
+pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.2" # + Flask API
```
Requires Python ≥ 3.10. Dependencies: [`regex`](https://pypi.org/project/regex/) (and `flask` for `[web]`).
@@ -97,6 +106,10 @@ engine.search("login OR sso", filters={"status": "open"})
`load_markdown()` adds `meta["section"]` (nearest heading), `meta["kind"]`
(`file`/`section`/`item`/`paragraph`) and `meta["markdown"]` (raw source).
+`dialect` (for `split="list"`) makes blocks line up with the HTML of your Markdown renderer:
+`"commonmark"` (default) or `"python-markdown"` (a list needs a blank line before it; nesting
+needs 4 spaces of indentation). Important if you link results to element ids in rendered HTML.
+
### `SearchEngine(source, *, reload_check=None, reload_interval=60)`
`source` is a list of blocks or a callable returning them. With a callable plus `reload_check`
diff --git a/pyproject.toml b/pyproject.toml
index 71634c2..7118644 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mdgrep"
-version = "0.1.0"
+version = "0.1.2"
description = "Boolean full-text search (substrings, phrases, regex, AND/OR/NOT, parentheses) over Markdown and other text blocks, with CLI, Flask API and a JavaScript search widget"
readme = "README.md"
license = "MIT"
diff --git a/src/mdgrep/__init__.py b/src/mdgrep/__init__.py
index 5870ffb..4e7000e 100644
--- a/src/mdgrep/__init__.py
+++ b/src/mdgrep/__init__.py
@@ -5,7 +5,7 @@ from .markdown import load_markdown, markdown_to_text, split_markdown
from .query import Query, QuerySyntaxError, QueryTimeout, compile_query
from .snippets import segments
-__version__ = "0.1.0"
+__version__ = "0.1.2"
STATIC_FILES = ("mdgrep-widget.js", "mdgrep-widget.css")
diff --git a/src/mdgrep/engine.py b/src/mdgrep/engine.py
index db7ab79..69f4046 100644
--- a/src/mdgrep/engine.py
+++ b/src/mdgrep/engine.py
@@ -103,12 +103,17 @@ class SearchEngine:
self._lock = threading.Lock()
self._token = reload_check() if reload_check else None
self._checked_at = time.monotonic()
- self._blocks: list[Block] = list(source() if callable(source) else source)
+ self._set_blocks(list(source() if callable(source) else source))
+
+ def _set_blocks(self, blocks: list[Block]) -> None:
+ # Text that is searched (title + text), precomputed once per load.
+ haystacks = [f"{b.title}\n{b.text}" if b.title else b.text for b in blocks]
+ self._data = (blocks, haystacks)
@property
def blocks(self) -> list[Block]:
self._maybe_reload()
- return self._blocks
+ return self._data[0]
def reload(self) -> None:
"""Reload blocks from the loader (no-op for a static block list)."""
@@ -116,7 +121,7 @@ class SearchEngine:
return
blocks = list(self._loader())
with self._lock:
- self._blocks = blocks
+ self._set_blocks(blocks)
self._token = self._reload_check() if self._reload_check else None
self._checked_at = time.monotonic()
@@ -160,14 +165,19 @@ class SearchEngine:
started = time.monotonic()
q = query if isinstance(query, Query) else compile_query(query)
deadline = started + time_budget if time_budget else None
- candidates = self._filter(self.blocks, filters, predicate)
+ self._maybe_reload()
+ blocks, haystacks = self._data
+ conditions = self._conditions(filters)
matched: list[Block] = []
truncated = False
- for block in candidates:
+ for i, block in enumerate(blocks):
+ if conditions and any(block.meta.get(k) not in allowed for k, allowed in conditions):
+ continue
+ if predicate is not None and not predicate(block):
+ continue
try:
- if q.matches(f"{block.title}\n{block.text}" if block.title else block.text,
- deadline=deadline):
+ if q.matches(haystacks[i], deadline=deadline):
matched.append(block)
except QueryTimeout:
truncated = True
@@ -214,16 +224,11 @@ class SearchEngine:
)
@staticmethod
- def _filter(blocks, filters, predicate):
+ def _conditions(filters):
conditions = []
for key, value in (filters or {}).items():
if value is None or value == "" or value == []:
continue
allowed = set(value) if isinstance(value, (list, tuple, set, frozenset)) else {value}
conditions.append((key, allowed))
- for block in blocks:
- if any(block.meta.get(k) not in allowed for k, allowed in conditions):
- continue
- if predicate is not None and not predicate(block):
- continue
- yield block
+ return conditions
diff --git a/src/mdgrep/markdown.py b/src/mdgrep/markdown.py
index 6a647f4..07eef14 100644
--- a/src/mdgrep/markdown.py
+++ b/src/mdgrep/markdown.py
@@ -9,6 +9,13 @@ Split modes:
Every block gets `meta["section"]` (nearest preceding heading text), `meta["kind"]`
("file", "section", "item" or "paragraph") and `meta["markdown"]` (the raw source).
+`dialect` (list mode) decides how list items are recognised, so blocks line up with the HTML
+your Markdown renderer produces:
+ commonmark (default) a list may interrupt a paragraph; only unindented markers start
+ top-level items
+ python-markdown a list needs a blank line before it; markers indented by up to 3 spaces
+ still start top-level items (nesting needs 4 spaces)
+
The searchable `text` is plain text with Markdown syntax removed. In `file` and `heading`
mode the heading becomes the block title (and is removed from the text); list items and
paragraphs have no title.
@@ -22,19 +29,22 @@ from typing import Callable, Iterable
from .engine import Block
-__all__ = ["load_markdown", "split_markdown", "markdown_to_text", "slugify", "SPLIT_MODES"]
+__all__ = ["load_markdown", "split_markdown", "markdown_to_text", "slugify", "SPLIT_MODES", "DIALECTS"]
SPLIT_MODES = ("file", "heading", "list", "paragraph")
+DIALECTS = ("commonmark", "python-markdown")
_HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
_FENCE = re.compile(r"^\s*(```|~~~)")
_LIST_ITEM = re.compile(r"^([-*+]|\d{1,9}[.)])\s+")
+_LIST_ITEM_PYMD = re.compile(r"^ {0,3}([-*+]|\d{1,9}\.)\s+")
_HR = re.compile(r"^\s*([-*_])(\s*\1){2,}\s*$")
def load_markdown(paths: str | Path | Iterable[str | Path], *, split: str = "paragraph",
meta: Callable[[Path], dict] | None = None, encoding: str = "utf-8",
- pattern: str = "*.md", relative_to: str | Path | None = None) -> list[Block]:
+ pattern: str = "*.md", relative_to: str | Path | None = None,
+ dialect: str = "commonmark") -> list[Block]:
"""Load Markdown files (directories are searched recursively for `pattern`).
`meta(path)` may return a dict merged into every block's meta of that file.
@@ -47,7 +57,7 @@ def load_markdown(paths: str | Path | Iterable[str | Path], *, split: str = "par
text = path.read_text(encoding=encoding)
extra = meta(path) if meta else {}
source = path.resolve().relative_to(base).as_posix() if base else str(path)
- blocks.extend(split_markdown(text, source=source, split=split, meta=extra))
+ blocks.extend(split_markdown(text, source=source, split=split, meta=extra, dialect=dialect))
return blocks
@@ -64,18 +74,19 @@ def _expand(paths, pattern):
def split_markdown(text: str, *, source: str = "", split: str = "paragraph",
- meta: dict | None = None) -> list[Block]:
- """Split one Markdown document into blocks (see module docstring for modes)."""
+ meta: dict | None = None, dialect: str = "commonmark") -> list[Block]:
+ """Split one Markdown document into blocks (see module docstring for modes and dialects)."""
if split not in SPLIT_MODES:
raise ValueError(f"split must be one of {SPLIT_MODES}, got {split!r}")
+ if dialect not in DIALECTS:
+ raise ValueError(f"dialect must be one of {DIALECTS}, got {dialect!r}")
meta = meta or {}
lines = text.splitlines()
- raw_blocks = {
- "file": _split_file,
- "heading": _split_heading,
- "list": _split_list,
- "paragraph": _split_paragraph,
- }[split](lines)
+ if split == "list":
+ raw_blocks = _split_list(lines, dialect == "python-markdown")
+ else:
+ raw_blocks = {"file": _split_file, "heading": _split_heading,
+ "paragraph": _split_paragraph}[split](lines)
blocks = []
for start, end, kind, section, title, anchor in raw_blocks:
@@ -127,7 +138,7 @@ def _split_heading(lines):
return out
-def _split_list(lines):
+def _split_list(lines, pymd=False):
out = []
section = ""
cur = None # [start, kind]
@@ -139,6 +150,7 @@ def _split_list(lines):
out.append((cur[0], end, cur[1], section, "", ""))
cur = None
+ item_re = _LIST_ITEM_PYMD if pymd else _LIST_ITEM
prev_blank = True
for i, line, in_fence, heading in _scan(lines):
if heading:
@@ -154,7 +166,9 @@ def _split_list(lines):
if in_fence:
if cur is None:
cur = [i, "paragraph"]
- elif _LIST_ITEM.match(line):
+ elif pymd and cur is not None and cur[1] == "paragraph" and not prev_blank:
+ pass # Python-Markdown: no list without blank line
+ elif item_re.match(line):
close(last_content + 1)
cur = [i, "item"]
elif _HR.match(line):
diff --git a/src/mdgrep/query.py b/src/mdgrep/query.py
index 9749581..c89deb3 100644
--- a/src/mdgrep/query.py
+++ b/src/mdgrep/query.py
@@ -133,7 +133,11 @@ class Term:
pattern: regex.Pattern = field(repr=False)
def evaluate(self, text: str, deadline: float | None) -> bool:
- return self.pattern.search(text, timeout=_remaining(deadline)) is not None
+ # Words and phrases are escaped literals and run in linear time; only user-supplied
+ # regular expressions can backtrack catastrophically and need the (costly) timeout.
+ if self.kind == "regex":
+ return self.pattern.search(text, timeout=_remaining(deadline)) is not None
+ return self.pattern.search(text) is not None
@dataclass
@@ -285,6 +289,7 @@ class Query:
if deadline is None and timeout is not None:
deadline = time.monotonic() + timeout
try:
+ _remaining(deadline)
return self._root.evaluate(text, deadline)
except TimeoutError as e:
raise QueryTimeout(str(e) or "Search time limit exceeded") from None
@@ -296,7 +301,8 @@ class Query:
found = []
try:
for term in self.positive_terms:
- for m in term.pattern.finditer(text, timeout=_remaining(deadline)):
+ timeout = _remaining(deadline) if term.kind == "regex" else None
+ for m in term.pattern.finditer(text, timeout=timeout):
if m.end() > m.start():
found.append((m.start(), m.end()))
except TimeoutError as e:
diff --git a/tests/test_markdown.py b/tests/test_markdown.py
index b4d3bb1..48a21ef 100644
--- a/tests/test_markdown.py
+++ b/tests/test_markdown.py
@@ -63,6 +63,29 @@ def test_split_list():
assert "not a heading" in blocks[6].text
+def test_dialect_list_interrupting_paragraph():
+ doc = "**1. Trend**\n* stories: a\n* why: b\n\n- real item\n"
+ common = split_markdown(doc, split="list")
+ assert [b.meta["kind"] for b in common] == ["paragraph", "item", "item", "item"]
+ pymd = split_markdown(doc, split="list", dialect="python-markdown")
+ assert [b.meta["kind"] for b in pymd] == ["paragraph", "item"]
+ assert "stories: a" in pymd[0].text
+
+
+def test_dialect_nesting_indent():
+ doc = "1. **Trend A**\n - why: x\n - how: y\n\n2. **Trend B**\n - nested: z\n"
+ common = split_markdown(doc, split="list")
+ assert [b.line for b in common] == [1, 5]
+ pymd = split_markdown(doc, split="list", dialect="python-markdown")
+ assert [b.line for b in pymd] == [1, 2, 3, 5] # 3 spaces: no nesting in Python-Markdown
+ assert "nested: z" in pymd[-1].text
+
+
+def test_invalid_dialect():
+ with pytest.raises(ValueError):
+ split_markdown("x", split="list", dialect="gfm")
+
+
def test_split_heading():
blocks = split_markdown(DOC, split="heading")
assert [(b.title, b.anchor) for b in blocks] == [