feat: mdgrep 0.1.0 – boolean full-text search for Markdown
- query language: substrings, phrases, /regex/, AND/OR/NOT (+ UND/ODER/NICHT),
parentheses; case-insensitive; ReDoS protection via regex timeouts;
syntax errors with stable code and position
- SearchEngine over in-memory blocks: filters, predicate, grouping, sorting,
paging, time budget, automatic reload, highlighted snippets
- Markdown loader with split modes file/heading/list/paragraph
- grep-like CLI (mdgrep), JSON output, grep exit codes
- Flask blueprint/app with JSON API and widget assets, dev server
- framework-free JS widget: live search, URL state, filters, paging,
syntax help, en/de labels, CSS custom properties for theming
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:29:44 +02:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-10-12 21:34:51 +02:00
|
|
|
|
## 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 |
|
docs: add German user manual (BEDIENUNGSANLEITUNG.md)
Installation, query syntax, CLI, Python API, Web API, widget, server
operation (gunicorn, systemd, nginx), security, recipes, error codes,
troubleshooting and release workflow. Linked from README.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:55:30 +02:00
|
|
|
|
|
feat: mdgrep 0.1.0 – boolean full-text search for Markdown
- query language: substrings, phrases, /regex/, AND/OR/NOT (+ UND/ODER/NICHT),
parentheses; case-insensitive; ReDoS protection via regex timeouts;
syntax errors with stable code and position
- SearchEngine over in-memory blocks: filters, predicate, grouping, sorting,
paging, time budget, automatic reload, highlighted snippets
- Markdown loader with split modes file/heading/list/paragraph
- grep-like CLI (mdgrep), JSON output, grep exit codes
- Flask blueprint/app with JSON API and widget assets, dev server
- framework-free JS widget: live search, URL state, filters, paging,
syntax help, en/de labels, CSS custom properties for theming
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:29:44 +02:00
|
|
|
|
## Installation
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
2026-10-15 00:05:29 +02:00
|
|
|
|
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
|
feat: mdgrep 0.1.0 – boolean full-text search for Markdown
- query language: substrings, phrases, /regex/, AND/OR/NOT (+ UND/ODER/NICHT),
parentheses; case-insensitive; ReDoS protection via regex timeouts;
syntax errors with stable code and position
- SearchEngine over in-memory blocks: filters, predicate, grouping, sorting,
paging, time budget, automatic reload, highlighted snippets
- Markdown loader with split modes file/heading/list/paragraph
- grep-like CLI (mdgrep), JSON output, grep exit codes
- Flask blueprint/app with JSON API and widget assets, dev server
- framework-free JS widget: live search, URL state, filters, paging,
syntax help, en/de labels, CSS custom properties for theming
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:29:44 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
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).
|
|
|
|
|
|
|
2026-10-12 19:37:40 +02:00
|
|
|
|
`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.
|
|
|
|
|
|
|
feat: mdgrep 0.1.0 – boolean full-text search for Markdown
- query language: substrings, phrases, /regex/, AND/OR/NOT (+ UND/ODER/NICHT),
parentheses; case-insensitive; ReDoS protection via regex timeouts;
syntax errors with stable code and position
- SearchEngine over in-memory blocks: filters, predicate, grouping, sorting,
paging, time budget, automatic reload, highlighted snippets
- Markdown loader with split modes file/heading/list/paragraph
- grep-like CLI (mdgrep), JSON output, grep exit codes
- Flask blueprint/app with JSON API and widget assets, dev server
- framework-free JS widget: live search, URL state, filters, paging,
syntax help, en/de labels, CSS custom properties for theming
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:29:44 +02:00
|
|
|
|
### `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
|