220 lines
9.3 KiB
Markdown
220 lines
9.3 KiB
Markdown
|
|
# 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.0" # library + CLI
|
|||
|
|
pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.0" # + 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).
|
|||
|
|
|
|||
|
|
### `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&<filter>=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
|
|||
|
|
<link rel="stylesheet" href="/api/mdgrep-static/mdgrep-widget.css">
|
|||
|
|
<div id="search"></div>
|
|||
|
|
<script src="/api/mdgrep-static/mdgrep-widget.js"></script>
|
|||
|
|
<script>
|
|||
|
|
MdGrep.mount(document.getElementById('search'), {
|
|||
|
|
endpoint: '/api/search',
|
|||
|
|
labels: 'de', // 'en' | 'de' | {…overrides}
|
|||
|
|
filters: [{ name: 'lang', label: 'Sprache',
|
|||
|
|
options: [{ value: '', label: 'Alle' }, { value: 'de', label: 'Deutsch' }] }],
|
|||
|
|
badgeFields: ['lang'], // meta keys shown as badges
|
|||
|
|
dateField: 'date', // meta key shown as date
|
|||
|
|
hrefField: 'href', // meta key with the link target (else #anchor)
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Without JavaScript code: `<div data-mdgrep data-endpoint="/api/search" data-labels="de"></div>`.
|
|||
|
|
|
|||
|
|
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
|