Skip to content

Commit 1e720ec

Browse files
codeafixclaude
andcommitted
Initial commit: mdlint-obsidian v0.1.0
Python library and CLI tool (mdlint) for linting Obsidian Flavored Markdown. Implements 22 rules across frontmatter, wikilinks, embeds, callouts, code blocks, formatting, footnotes, tables, and math. 144 tests, 97% coverage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
0 parents  commit 1e720ec

31 files changed

Lines changed: 2408 additions & 0 deletions

.gitignore

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Python
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
*.so
6+
*.egg
7+
*.egg-info/
8+
dist/
9+
build/
10+
eggs/
11+
parts/
12+
var/
13+
sdist/
14+
wheels/
15+
pip-wheel-metadata/
16+
share/python-wheels/
17+
.eggs/
18+
MANIFEST
19+
20+
# Virtual environments
21+
venv/
22+
env/
23+
.venv/
24+
.env/
25+
ENV/
26+
env.bak/
27+
venv.bak/
28+
29+
# Testing
30+
.pytest_cache/
31+
.coverage
32+
htmlcov/
33+
.tox/
34+
.nox/
35+
coverage.xml
36+
*.cover
37+
*.py,cover
38+
.hypothesis/
39+
40+
# IDEs
41+
.idea/
42+
.vscode/
43+
*.swp
44+
*.swo
45+
*~
46+
47+
# macOS
48+
.DS_Store
49+
.AppleDouble
50+
.LSOverride
51+
52+
# Distribution / packaging
53+
*.tar.gz
54+
*.whl
55+
56+
# mypy
57+
.mypy_cache/
58+
.dmypy.json
59+
dmypy.json
60+
61+
# pyright
62+
pyrightconfig.json

CONTRIBUTING.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Contributing to mdlint-obsidian
2+
3+
Thank you for your interest in contributing!
4+
5+
## Adding a New Rule
6+
7+
1. **Pick the right module.** Rules are grouped by topic in `mdlint_obsidian/rules/`. Add your rule to the most relevant existing module, or create a new one if the topic is genuinely distinct.
8+
9+
2. **Implement the check.** Each rule module exposes a `check(lines, vault_path=None)` function that returns a list of `LintError` objects. Follow this pattern:
10+
11+
```python
12+
from ..models import LintError, Severity
13+
from ..utils import is_in_code_block, get_frontmatter_end
14+
15+
def check(lines: list[str], vault_path=None) -> list[LintError]:
16+
errors = []
17+
fm_end = get_frontmatter_end(lines)
18+
for i, line in enumerate(lines):
19+
if i < fm_end:
20+
continue
21+
if is_in_code_block(lines, i):
22+
continue
23+
# ... your check logic ...
24+
return errors
25+
```
26+
27+
3. **Name the rule in kebab-case.** Example: `my-new-rule`. Document it in the module docstring and add it to the rules table in `README.md`.
28+
29+
4. **Assign the right severity.** Use `Severity.ERROR` for structural problems that break rendering, and `Severity.WARNING` for issues that are valid in some contexts (e.g., custom CSS callout types, unresolved links when the vault root is unknown).
30+
31+
5. **Skip code blocks.** Call `is_in_code_block(lines, i)` for every line you inspect. This is the most important correctness requirement — content inside fences must never be flagged.
32+
33+
6. **Skip frontmatter.** Call `get_frontmatter_end(lines)` and skip lines before that index, unless your rule specifically applies to frontmatter.
34+
35+
7. **Write tests.** Add a test file `tests/test_<module>.py` (or add to an existing one). Every rule needs at minimum:
36+
- A test showing valid content produces no errors.
37+
- A test for each error condition that asserts the correct `rule` name and `line` number.
38+
- A test confirming the rule is silent when the violation is inside a code block.
39+
40+
8. **Register the module.** Import and call your module in `mdlint_obsidian/linter.py`'s `validate()` function.
41+
42+
## Running Tests
43+
44+
```bash
45+
pip install -e ".[dev]"
46+
pytest
47+
```
48+
49+
**Coverage** — aim to keep rule module coverage at 95%+. Run:
50+
51+
```bash
52+
pytest --cov=mdlint_obsidian --cov-report=term-missing
53+
```
54+
55+
Current baseline: **89% overall** (135 tests). The CLI (`cli.py`) is intentionally excluded from the per-rule target — it has 0% coverage because it requires subprocess/integration tests. Expected uncovered areas:
56+
57+
| Module | Notes |
58+
|--------|-------|
59+
| `cli.py` | No unit tests; test manually or with subprocess integration tests |
60+
| `models.py` | The `__eq__` override — covered implicitly but not via direct equality assertion |
61+
| Rule branches | Conservative early-returns in `math.py`, YAML error line extraction in `frontmatter.py` |
62+
63+
## Code Style
64+
65+
- Python 3.10+, type hints on all public functions.
66+
- Keep rule logic self-contained within its module.
67+
- Prefer clarity over cleverness — these checks run on user notes, so false positives are worse than false negatives.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 obsidian-linter contributors
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# mdlint-obsidian
2+
3+
<!-- Badges placeholder -->
4+
<!-- [![PyPI version](https://badge.fury.io/py/mdlint-obsidian.svg)](https://badge.fury.io/py/mdlint-obsidian) -->
5+
<!-- [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) -->
6+
<!-- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -->
7+
<!-- [![Tests](https://github.com/you/mdlint-obsidian/actions/workflows/test.yml/badge.svg)](https://github.com/you/mdlint-obsidian/actions) -->
8+
9+
A Python library and CLI tool that lints **Obsidian Flavored Markdown** files.
10+
11+
It checks for structural problems — unclosed wikilinks, invalid frontmatter, malformed tables, unmatched math delimiters and more — so you catch issues before they cause broken renders in your vault.
12+
13+
---
14+
15+
## Installation
16+
17+
```bash
18+
pip install mdlint-obsidian
19+
```
20+
21+
Or with [pipx](https://pypa.github.io/pipx/) for an isolated CLI install:
22+
23+
```bash
24+
pipx install mdlint-obsidian
25+
```
26+
27+
---
28+
29+
## Usage
30+
31+
### CLI
32+
33+
```bash
34+
# Lint a single file
35+
mdlint path/to/note.md
36+
37+
# Lint an entire vault (all .md files recursively)
38+
mdlint path/to/vault/
39+
40+
# Enable broken-link checking (requires vault root)
41+
mdlint note.md --vault path/to/vault/
42+
43+
# Show only errors (suppress warnings)
44+
mdlint note.md --severity error
45+
46+
# Machine-readable JSON output
47+
mdlint note.md --format json
48+
```
49+
50+
**Exit codes:** `0` if no errors (warnings alone do not fail), `1` if any errors are found.
51+
52+
**Example output (text format):**
53+
54+
```
55+
notes/my-note.md:5: [ERROR] unclosed-wikilink: Wikilink [[ is not closed with ]]
56+
notes/my-note.md:12: [WARNING] broken-link: Link [[Missing Note]] does not resolve to an existing note
57+
```
58+
59+
**Example output (JSON format):**
60+
61+
```json
62+
[
63+
{
64+
"file": "notes/my-note.md",
65+
"line": 5,
66+
"rule": "unclosed-wikilink",
67+
"severity": "error",
68+
"message": "Wikilink [[ is not closed with ]]"
69+
}
70+
]
71+
```
72+
73+
### Python library
74+
75+
```python
76+
from mdlint_obsidian import validate, LintError, Severity
77+
78+
content = open("my-note.md").read()
79+
80+
# Basic validation
81+
errors = validate(content)
82+
83+
# With vault path for broken-link checking
84+
errors = validate(content, vault_path="/path/to/vault")
85+
86+
for error in errors:
87+
print(f"Line {error.line} [{error.severity.value.upper()}] {error.rule}: {error.message}")
88+
```
89+
90+
The `validate()` function returns a list of `LintError` dataclass instances:
91+
92+
```python
93+
@dataclass
94+
class LintError:
95+
rule: str # e.g. "unclosed-wikilink"
96+
severity: Severity # Severity.ERROR or Severity.WARNING
97+
line: int # 1-indexed line number
98+
message: str
99+
```
100+
101+
**Filtering by severity:**
102+
103+
```python
104+
from mdlint_obsidian import validate, Severity
105+
106+
errors = validate(content)
107+
errors_only = [e for e in errors if e.severity == Severity.ERROR]
108+
warnings_only = [e for e in errors if e.severity == Severity.WARNING]
109+
```
110+
111+
---
112+
113+
## Rules
114+
115+
All rules skip content inside fenced code blocks (``` ` ``` `` or `~~~`).
116+
117+
### Frontmatter
118+
119+
| Rule | Severity | Description |
120+
|------|----------|-------------|
121+
| `frontmatter-not-first` | ERROR | A `---...---` block that parses as YAML frontmatter exists but does not start at line 1 |
122+
| `frontmatter-invalid-yaml` | ERROR | The frontmatter block cannot be parsed as valid YAML |
123+
| `frontmatter-unclosed` | ERROR | An opening `---` at line 1 has no closing `---` |
124+
125+
### Wikilinks
126+
127+
| Rule | Severity | Description |
128+
|------|----------|-------------|
129+
| `unclosed-wikilink` | ERROR | `[[` without a matching `]]` |
130+
| `empty-wikilink` | ERROR | `[[]]` with no content |
131+
| `wikilink-invalid-chars` | ERROR | Wikilink target contains `#` or `^` in invalid positions (e.g. multiple `#`, or `^` before `#`) |
132+
| `broken-link` | WARNING | `[[Note Name]]` does not resolve to an existing `.md` file in the vault (only when `--vault` is provided) |
133+
134+
### Embeds
135+
136+
| Rule | Severity | Description |
137+
|------|----------|-------------|
138+
| `unclosed-embed` | ERROR | `![[` without a matching `]]` |
139+
| `empty-embed` | ERROR | `![[]]` with no content |
140+
| `embed-invalid-dimension` | ERROR | `![[file\|WxH]]` where the dimension suffix is malformed (e.g. `300x` or `x200`) |
141+
142+
### Callouts
143+
144+
| Rule | Severity | Description |
145+
|------|----------|-------------|
146+
| `callout-invalid-type` | WARNING | `> [!type]` where type is not one of the 13 built-in Obsidian types or their aliases (custom CSS types are valid, hence warning) |
147+
| `callout-missing-continuation` | ERROR | The line immediately after a callout header is non-empty and does not start with `>` |
148+
| `callout-invalid-modifier` | ERROR | The modifier after the callout type is not `+` or `-` |
149+
150+
**Built-in callout types:** `note`, `abstract`/`summary`/`tldr`, `info`, `todo`, `tip`/`hint`/`important`, `success`/`check`/`done`, `question`/`help`/`faq`, `warning`/`caution`/`attention`, `failure`/`fail`/`missing`, `danger`/`error`, `bug`, `example`, `quote`/`cite`
151+
152+
### Code Blocks
153+
154+
| Rule | Severity | Description |
155+
|------|----------|-------------|
156+
| `unclosed-code-block` | ERROR | An opening fence (`` ``` `` or `~~~`) has no matching closing fence |
157+
158+
### Formatting
159+
160+
| Rule | Severity | Description |
161+
|------|----------|-------------|
162+
| `unclosed-highlight` | ERROR | `==` opened but not closed on the same line |
163+
| `unclosed-comment` | ERROR | `%%` opened but never closed (document-level) |
164+
165+
### Footnotes
166+
167+
| Rule | Severity | Description |
168+
|------|----------|-------------|
169+
| `orphaned-footnote-ref` | ERROR | `[^id]` reference in the body with no matching `[^id]:` definition |
170+
| `orphaned-footnote-def` | ERROR | `[^id]:` definition with no matching `[^id]` reference in the body |
171+
172+
### Tables
173+
174+
| Rule | Severity | Description |
175+
|------|----------|-------------|
176+
| `table-missing-separator` | ERROR | Table header row is not followed by a separator row (`\|---|`) |
177+
| `table-inconsistent-columns` | ERROR | A table body row has a different column count than the header |
178+
179+
### Math
180+
181+
| Rule | Severity | Description |
182+
|------|----------|-------------|
183+
| `unclosed-math-block` | ERROR | `$$` block opened but never closed (document-level) |
184+
| `unclosed-inline-math` | WARNING | `$` opened but not closed on the same line (conservative: ignores `$100`-style currency) |
185+
186+
---
187+
188+
## Development
189+
190+
```bash
191+
git clone https://github.com/you/mdlint-obsidian.git
192+
cd mdlint-obsidian
193+
pip install -e ".[dev]"
194+
pytest
195+
pytest --cov=mdlint_obsidian --cov-report=term-missing
196+
```
197+
198+
See [CONTRIBUTING.md](CONTRIBUTING.md) for information on adding new rules.
199+
200+
---
201+
202+
## License
203+
204+
MIT — see [LICENSE](LICENSE).

mdlint_obsidian/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""obsidian-linter: lint Obsidian Flavored Markdown files."""
2+
3+
from .linter import validate
4+
from .models import LintError, Severity
5+
6+
__all__ = ["validate", "LintError", "Severity"]

0 commit comments

Comments
 (0)