|
| 1 | +# mdlint-obsidian |
| 2 | + |
| 3 | +<!-- Badges placeholder --> |
| 4 | +<!-- [](https://badge.fury.io/py/mdlint-obsidian) --> |
| 5 | +<!-- [](https://www.python.org/downloads/) --> |
| 6 | +<!-- [](https://opensource.org/licenses/MIT) --> |
| 7 | +<!-- [](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). |
0 commit comments