# mdgrep Boolean full-text search over Markdown (and any other text) – as a **Python library**, a **grep-like command line tool**, a **Flask JSON API** and a **framework-free JavaScript search widget**. - substrings, `"phrases"` and `/regular expressions/`, always case-insensitive (Unicode-aware) - `AND` / `OR` / `NOT` with parentheses – German keywords `UND` / `ODER` / `NICHT` work too - results as "boxes" (blocks) with highlighted context, grouping, sorting, filters and paging - 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 ## Installation ```bash pip install "mdgrep @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.1" # library + CLI pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.1" # + Flask API ``` Requires Python ≥ 3.10. Dependencies: [`regex`](https://pypi.org/project/regex/) (and `flask` for `[web]`). ## Query syntax | Query | Meaning | |---|---| | `rust` | substring, case-insensitive – also matches *Rust*, *trusted*, *Rustacean* | | `"open source"` | phrase; any whitespace (incl. line breaks) between the words | | `/llms?\b/` | regular expression (`\/` for a literal slash), case-insensitive | | `a b` · `a AND b` · `a UND b` · `a & b` | both | | `a OR b` · `a ODER b` · `a \| b` | at least one | | `NOT a` · `NICHT a` · `-a` · `!a` | must not occur | | `( … )` | grouping | Precedence: `NOT` > `AND` > `OR`, so `a OR b AND c` means `a OR (b AND c)`. Keywords are only recognised in upper case (`and` is a normal search word). Whole words: use a regex, e.g. `/\bgo\b/`. Examples: `(rust OR go) AND NOT crypto` · `"machine learning" -/pytorch|tensorflow/` · `llm (privacy OR datenschutz)` Invalid queries raise `QuerySyntaxError` with a stable `code`, a `message` and the 0-based `position`: `empty_query`, `empty_phrase`, `unterminated_phrase`, `unterminated_regex`, `invalid_regex`, `missing_operand`, `unexpected_paren`, `unexpected_token`, `unclosed_paren`, `empty_group`, `too_long`, `too_many_terms`. ## Command line ```bash mdgrep '(rust OR go) AND NOT crypto' notes/ # search all *.md below notes/ mdgrep '/llms?\b/' docs/*.md --split list # one result per list item mdgrep 'todo' . -l # file names only mdgrep 'release' . -c # number of matching blocks mdgrep '"open source"' . --json # JSON (same format as the Web API) python -m mdgrep --help ``` `--split` chooses the unit that is searched and shown: `paragraph` (default), `list` (top-level list items, other text as paragraphs), `heading` (sections) or `file`. Exit codes as with grep: `0` match, `1` no match, `2` error (syntax error, time limit). ## Python API ```python from mdgrep import compile_query, load_markdown, SearchEngine, Block # 1. Just the query language – on any string 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)] – for highlighting # 2. Markdown files → blocks → search blocks = load_markdown("docs/", split="list", relative_to="docs/", meta=lambda path: {"lang": path.stem.rsplit("_", 1)[-1]}) engine = SearchEngine(blocks) result = engine.search("rust", filters={"lang": "de"}, limit=20) for group in result.groups: print(group.block.source, group.block.line, group.segments) result.to_dict() # JSON-ready, see "JSON format" # 3. Your own data – any text source engine = SearchEngine([ Block(id="42", title="Ticket 42", text="Login fails with SSO", meta={"status": "open"}), Block(id="43", title="Ticket 43", text="Dark mode toggle broken", meta={"status": "closed"}), ]) engine.search("login OR sso", filters={"status": "open"}) ``` ### `Block` | Field | Meaning | |---|---| | `id` | unique id | | `text` | searched and shown as snippet | | `title` | searched and shown as heading (don't repeat it in `text`) | | `anchor` | optional fragment id, used by the widget as link target if no `href` is given | | `source`, `line` | origin (file, 1-based line) | | `meta` | free dict, passed through unchanged (filters work on it) | `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` (returns any comparable token, e.g. newest file mtime) blocks are reloaded automatically when the token changes – checked at most every `reload_interval` seconds: ```python engine = SearchEngine(lambda: load_markdown("docs/"), reload_check=lambda: max(p.stat().st_mtime for p in Path("docs").rglob("*.md"))) ``` ### `engine.search(query, **options)` | Option | Default | Meaning | |---|---|---| | `filters` | `None` | `{meta_key: value or [values]}`, all keys must match; empty values are ignored | | `predicate` | `None` | extra `callable(block) -> bool` | | `group_by` | `None` | `callable(block) -> key or None`; one result per key, the others in `also` | | `sort_key`, `reverse` | original order | sort matching blocks (e.g. newest first) | | `offset`, `limit` | `0`, `30` | paging, counted in groups (`limit=None` = all) | | `time_budget` | `1.5` | seconds for the whole search; on overrun `truncated=True` | | `snippet_chars`, `context` | `700`, `120` | longer texts are cut to windows around the hits | ## Web API (Flask) ```python from mdgrep import SearchEngine, load_markdown from mdgrep.web import create_app, create_blueprint engine = SearchEngine(lambda: load_markdown("docs", split="list", relative_to="docs")) app = create_app(engine, filters=["lang"]) # standalone: GET /search # or inside an existing app: app.register_blueprint(create_blueprint(engine, filters=["lang"]), url_prefix="/api") ``` `GET /search?q=…&offset=0&limit=30&=value` – for each name in `filters` an equally named parameter (`""`/`all` = no restriction, `a,b` = several values). The blueprint also serves the widget assets under `/mdgrep-static/`. `create_blueprint()` accepts the search options above plus `default_limit`, `max_limit`, `max_query_length` and `hide_meta` (meta keys removed from responses, default `("markdown",)`). Development server (serves a static site and the API together): ```bash python -m mdgrep.web --dir examples/docs --split list --static examples/site --port 8000 # → http://127.0.0.1:8000/ (API: /api/search) ``` For production run the app with a WSGI server (e.g. `gunicorn -w 2 module:app`) behind a reverse proxy with rate limiting. ### JSON format ```json { "query": "rust", "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": {}}] }] } ``` Errors: HTTP 400 `{"error": {"code": "unclosed_paren", "message": "…", "position": 0}}`. ## JavaScript widget Framework-free, live search with debounce, cancels outdated requests, keeps the query in the URL (shareable, back button), filters, "load more", collapsible syntax help, error messages with position, English and German labels. All result text is inserted via `textContent` (no XSS). ```html ``` Without JavaScript code: `
`. Other options: `live`, `debounce` (300 ms), `minChars` (2), `limit` (30), `syncUrl`, `autofocus`, `help`, `href(block)`, `formatBadge(field, value, block)`, `alsoText(block)`. The widget fires a `mdgrep:results` event with the response on its root element. For static sites copy the assets instead of serving them from Flask: ```python import mdgrep mdgrep.export_static("site/static") # writes mdgrep-widget.js / .css ``` Theming via CSS custom properties (defaults are light): `--mdgrep-text`, `--mdgrep-muted`, `--mdgrep-surface`, `--mdgrep-border`, `--mdgrep-accent`, `--mdgrep-mark-bg`, `--mdgrep-mark-text`, `--mdgrep-error`, `--mdgrep-radius`. ## Development ```bash python -m venv .venv && .venv/bin/pip install -e ".[dev]" .venv/bin/pytest ``` ## License MIT