Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package rumdl for openSUSE:Factory checked 
in at 2026-03-20 21:26:38
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/rumdl (Old)
 and      /work/SRC/openSUSE:Factory/.rumdl.new.8177 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "rumdl"

Fri Mar 20 21:26:38 2026 rev:47 rq:1341565 version:0.1.57

Changes:
--------
--- /work/SRC/openSUSE:Factory/rumdl/rumdl.changes      2026-03-19 
17:41:20.643670878 +0100
+++ /work/SRC/openSUSE:Factory/.rumdl.new.8177/rumdl.changes    2026-03-20 
21:27:29.680686659 +0100
@@ -1,0 +2,33 @@
+Fri Mar 20 13:52:22 UTC 2026 - Johannes Kastl 
<[email protected]>
+
+- Update to version 0.1.57:
+  * Fixed
+    - MD041: MDX-style inline disable comments ({/* <!--
+      rumdl-disable MD041 --> */}) now correctly suppress MD041
+      (#538)
+    - MD041: Extracted shared first_content_line_idx() helper to
+      prevent check/fix path inconsistencies
+- Update to version 0.1.56:
+  * Added
+    - MD057: Obsidian attachment folder auto-detection when flavor
+      = "obsidian" is set — supports all 4 Obsidian attachment
+      modes (vault root, named folder, same as file, subfolder
+      under file) (#537)
+    - MD057: New search-paths config option for specifying
+      additional directories to search when resolving relative
+      links
+- Update to version 0.1.55:
+  * Fixed
+    - MD064: Fixed false positives inside indented fenced code
+      blocks when --- horizontal rules appear later in the document
+      (#536)
+      - Replaced Options::all() with an explicit pulldown-cmark
+        option allowlist, excluding
+        ENABLE_YAML_STYLE_METADATA_BLOCKS which misinterprets ---
+        horizontal rules as YAML metadata delimiters (works around
+        pulldown-cmark#1000)
+      - rumdl handles front matter detection independently and
+        correctly (requires --- at line 1, not anywhere in the
+        document)
+
+-------------------------------------------------------------------

Old:
----
  rumdl-0.1.54.obscpio

New:
----
  rumdl-0.1.57.obscpio

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ rumdl.spec ++++++
--- /var/tmp/diff_new_pack.0Lz9Ue/_old  2026-03-20 21:27:30.808733546 +0100
+++ /var/tmp/diff_new_pack.0Lz9Ue/_new  2026-03-20 21:27:30.808733546 +0100
@@ -17,7 +17,7 @@
 
 
 Name:           rumdl
-Version:        0.1.54
+Version:        0.1.57
 Release:        0
 Summary:        Markdown Linter written in Rust
 License:        MIT

++++++ _service ++++++
--- /var/tmp/diff_new_pack.0Lz9Ue/_old  2026-03-20 21:27:30.852735375 +0100
+++ /var/tmp/diff_new_pack.0Lz9Ue/_new  2026-03-20 21:27:30.852735375 +0100
@@ -3,7 +3,7 @@
     <param name="url">https://github.com/rvben/rumdl.git</param>
     <param name="scm">git</param>
     <param name="submodules">enable</param>
-    <param name="revision">v0.1.54</param>
+    <param name="revision">v0.1.57</param>
     <param name="match-tag">v*.*.*</param>
     <param name="versionformat">@PARENT_TAG@</param>
     <param name="versionrewrite-pattern">v(.*)</param>

++++++ _servicedata ++++++
--- /var/tmp/diff_new_pack.0Lz9Ue/_old  2026-03-20 21:27:30.876736372 +0100
+++ /var/tmp/diff_new_pack.0Lz9Ue/_new  2026-03-20 21:27:30.880736539 +0100
@@ -1,6 +1,6 @@
 <servicedata>
 <service name="tar_scm">
                 <param name="url">https://github.com/rvben/rumdl.git</param>
-              <param 
name="changesrevision">8eeed34c6f8f3bacc43f59a6a34f3c15dd594b20</param></service></servicedata>
+              <param 
name="changesrevision">b52183a24937296947580d86711ecde803ba6e83</param></service></servicedata>
 (No newline at EOF)
 

++++++ rumdl-0.1.54.obscpio -> rumdl-0.1.57.obscpio ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/CHANGELOG.md 
new/rumdl-0.1.57/CHANGELOG.md
--- old/rumdl-0.1.54/CHANGELOG.md       2026-03-18 17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/CHANGELOG.md       2026-03-20 13:26:48.000000000 +0100
@@ -7,6 +7,28 @@
 
 ## [Unreleased]
 
+## [0.1.57] - 2026-03-20
+
+### Fixed
+
+- **MD041**: MDX-style inline disable comments (`{/* <!-- rumdl-disable MD041 
--> */}`) now correctly suppress MD041 
([#538](https://github.com/rvben/rumdl/issues/538))
+- **MD041**: Extracted shared `first_content_line_idx()` helper to prevent 
check/fix path inconsistencies
+
+## [0.1.56] - 2026-03-19
+
+### Added
+
+- **MD057**: Obsidian attachment folder auto-detection when `flavor = 
"obsidian"` is set — supports all 4 Obsidian attachment modes (vault root, 
named folder, same as file, subfolder under file) 
([#537](https://github.com/rvben/rumdl/issues/537))
+- **MD057**: New `search-paths` config option for specifying additional 
directories to search when resolving relative links
+
+## [0.1.55] - 2026-03-19
+
+### Fixed
+
+- **MD064**: Fixed false positives inside indented fenced code blocks when 
`---` horizontal rules appear later in the document 
([#536](https://github.com/rvben/rumdl/issues/536))
+  - Replaced `Options::all()` with an explicit pulldown-cmark option 
allowlist, excluding `ENABLE_YAML_STYLE_METADATA_BLOCKS` which misinterprets 
`---` horizontal rules as YAML metadata delimiters (works around 
[pulldown-cmark#1000](https://github.com/pulldown-cmark/pulldown-cmark/issues/1000))
+  - rumdl handles front matter detection independently and correctly (requires 
`---` at line 1, not anywhere in the document)
+
 ## [0.1.54] - 2026-03-18
 
 ### Fixed
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/Cargo.lock new/rumdl-0.1.57/Cargo.lock
--- old/rumdl-0.1.54/Cargo.lock 2026-03-18 17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/Cargo.lock 2026-03-20 13:26:48.000000000 +0100
@@ -2247,7 +2247,7 @@
 
 [[package]]
 name = "rumdl"
-version = "0.1.54"
+version = "0.1.57"
 dependencies = [
  "anyhow",
  "assert_cmd",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/Cargo.toml new/rumdl-0.1.57/Cargo.toml
--- old/rumdl-0.1.54/Cargo.toml 2026-03-18 17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/Cargo.toml 2026-03-20 13:26:48.000000000 +0100
@@ -1,6 +1,6 @@
 [package]
 name = "rumdl"
-version = "0.1.54"
+version = "0.1.57"
 edition = "2024"
 rust-version = "1.94.0"
 description = "A fast Markdown linter written in Rust (Ru(st) MarkDown Linter)"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/README.md new/rumdl-0.1.57/README.md
--- old/rumdl-0.1.54/README.md  2026-03-18 17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/README.md  2026-03-20 13:26:48.000000000 +0100
@@ -196,7 +196,7 @@
 mise install rumdl
 
 # Use a specific version for the project
-mise use [email protected]
+mise use [email protected]
 ```
 
 ### Using Nix (macOS/Linux)
@@ -346,7 +346,7 @@
 ```yaml
 repos:
   - repo: https://github.com/rvben/rumdl-pre-commit
-    rev: v0.1.54
+    rev: v0.1.57
     hooks:
       - id: rumdl      # Lint only (fails on issues)
       - id: rumdl-fmt  # Auto-format and fail if issues remain
@@ -368,7 +368,7 @@
 ```yaml
 repos:
   - repo: https://github.com/rvben/rumdl-pre-commit
-    rev: v0.1.54
+    rev: v0.1.57
     hooks:
       - id: rumdl
         args: [--no-exclude]  # Disable all exclude patterns
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/docs/md057.md 
new/rumdl-0.1.57/docs/md057.md
--- old/rumdl-0.1.54/docs/md057.md      2026-03-18 17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/docs/md057.md      2026-03-20 13:26:48.000000000 +0100
@@ -140,6 +140,42 @@
 
 Fragment (`#section`) and query (`?v=1`) suffixes are preserved in the 
suggested fix.
 
+### `search-paths`
+
+Additional directories to search when a relative link target is not found 
relative to the
+file's directory. Paths are resolved relative to the project root (where 
`.rumdl.toml` or
+`pyproject.toml` is found), or relative to the current working directory.
+
+```toml
+# .rumdl.toml
+[MD057]
+search-paths = ["assets", "images", "attachments"]
+```
+
+With this configuration, a link like `![photo](photo.png)` will first be 
checked relative
+to the markdown file's directory. If not found there, MD057 will also look in 
`assets/photo.png`,
+`images/photo.png`, and `attachments/photo.png`.
+
+**Obsidian users:** When `flavor = "obsidian"` is set in the global config, 
the attachment
+folder is auto-detected from `.obsidian/app.json`, so this option is typically 
not needed.
+Use it for custom setups or non-Obsidian tools with similar asset directory 
conventions.
+
+```toml
+# .rumdl.toml — Obsidian auto-detection (no search-paths needed)
+[global]
+flavor = "obsidian"
+```
+
+Obsidian supports 4 attachment location modes configured via 
`attachmentFolderPath` in
+`.obsidian/app.json`:
+
+| Setting | `attachmentFolderPath` | Resolution |
+|---------|----------------------|------------|
+| Vault folder (root) | `""` (empty) | `<vault-root>/` |
+| Specified folder | `"Attachments"` | `<vault-root>/Attachments/` |
+| Same folder as file | `"./"` | Same directory as the markdown file |
+| Subfolder under file | `"./assets"` | `<file-dir>/assets/` |
+
 ### Build-Generated Files
 
 Documentation sites often compile markdown files to HTML during build. MD057 
automatically
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/npm/cli-darwin-arm64/package.json 
new/rumdl-0.1.57/npm/cli-darwin-arm64/package.json
--- old/rumdl-0.1.54/npm/cli-darwin-arm64/package.json  2026-03-18 
17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/npm/cli-darwin-arm64/package.json  2026-03-20 
13:26:48.000000000 +0100
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-darwin-arm64",
-  "version": "0.1.54",
+  "version": "0.1.57",
   "description": "rumdl binary for macOS ARM64 (Apple Silicon)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/npm/cli-darwin-x64/package.json 
new/rumdl-0.1.57/npm/cli-darwin-x64/package.json
--- old/rumdl-0.1.54/npm/cli-darwin-x64/package.json    2026-03-18 
17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/npm/cli-darwin-x64/package.json    2026-03-20 
13:26:48.000000000 +0100
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-darwin-x64",
-  "version": "0.1.54",
+  "version": "0.1.57",
   "description": "rumdl binary for macOS x64 (Intel)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/npm/cli-linux-arm64/package.json 
new/rumdl-0.1.57/npm/cli-linux-arm64/package.json
--- old/rumdl-0.1.54/npm/cli-linux-arm64/package.json   2026-03-18 
17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/npm/cli-linux-arm64/package.json   2026-03-20 
13:26:48.000000000 +0100
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-linux-arm64",
-  "version": "0.1.54",
+  "version": "0.1.57",
   "description": "rumdl binary for Linux ARM64 (glibc)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/npm/cli-linux-arm64-musl/package.json 
new/rumdl-0.1.57/npm/cli-linux-arm64-musl/package.json
--- old/rumdl-0.1.54/npm/cli-linux-arm64-musl/package.json      2026-03-18 
17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/npm/cli-linux-arm64-musl/package.json      2026-03-20 
13:26:48.000000000 +0100
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-linux-arm64-musl",
-  "version": "0.1.54",
+  "version": "0.1.57",
   "description": "rumdl binary for Linux ARM64 (musl/Alpine)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/npm/cli-linux-x64/package.json 
new/rumdl-0.1.57/npm/cli-linux-x64/package.json
--- old/rumdl-0.1.54/npm/cli-linux-x64/package.json     2026-03-18 
17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/npm/cli-linux-x64/package.json     2026-03-20 
13:26:48.000000000 +0100
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-linux-x64",
-  "version": "0.1.54",
+  "version": "0.1.57",
   "description": "rumdl binary for Linux x64 (glibc)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/npm/cli-linux-x64-musl/package.json 
new/rumdl-0.1.57/npm/cli-linux-x64-musl/package.json
--- old/rumdl-0.1.54/npm/cli-linux-x64-musl/package.json        2026-03-18 
17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/npm/cli-linux-x64-musl/package.json        2026-03-20 
13:26:48.000000000 +0100
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-linux-x64-musl",
-  "version": "0.1.54",
+  "version": "0.1.57",
   "description": "rumdl binary for Linux x64 (musl/Alpine)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/npm/cli-win32-x64/package.json 
new/rumdl-0.1.57/npm/cli-win32-x64/package.json
--- old/rumdl-0.1.54/npm/cli-win32-x64/package.json     2026-03-18 
17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/npm/cli-win32-x64/package.json     2026-03-20 
13:26:48.000000000 +0100
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-win32-x64",
-  "version": "0.1.54",
+  "version": "0.1.57",
   "description": "rumdl binary for Windows x64",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/npm/rumdl/package.json 
new/rumdl-0.1.57/npm/rumdl/package.json
--- old/rumdl-0.1.54/npm/rumdl/package.json     2026-03-18 17:23:42.000000000 
+0100
+++ new/rumdl-0.1.57/npm/rumdl/package.json     2026-03-20 13:26:48.000000000 
+0100
@@ -1,6 +1,6 @@
 {
   "name": "rumdl",
-  "version": "0.1.54",
+  "version": "0.1.57",
   "description": "A fast Markdown linter written in Rust",
   "license": "MIT",
   "repository": {
@@ -33,12 +33,12 @@
     "node": ">=18.0.0"
   },
   "optionalDependencies": {
-    "@rumdl/cli-darwin-x64": "0.1.54",
-    "@rumdl/cli-darwin-arm64": "0.1.54",
-    "@rumdl/cli-linux-x64": "0.1.54",
-    "@rumdl/cli-linux-arm64": "0.1.54",
-    "@rumdl/cli-linux-x64-musl": "0.1.54",
-    "@rumdl/cli-linux-arm64-musl": "0.1.54",
-    "@rumdl/cli-win32-x64": "0.1.54"
+    "@rumdl/cli-darwin-x64": "0.1.57",
+    "@rumdl/cli-darwin-arm64": "0.1.57",
+    "@rumdl/cli-linux-x64": "0.1.57",
+    "@rumdl/cli-linux-arm64": "0.1.57",
+    "@rumdl/cli-linux-x64-musl": "0.1.57",
+    "@rumdl/cli-linux-arm64-musl": "0.1.57",
+    "@rumdl/cli-win32-x64": "0.1.57"
   }
 }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/src/code_block_tools/processor.rs 
new/rumdl-0.1.57/src/code_block_tools/processor.rs
--- old/rumdl-0.1.54/src/code_block_tools/processor.rs  2026-03-18 
17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/src/code_block_tools/processor.rs  2026-03-20 
13:26:48.000000000 +0100
@@ -11,7 +11,8 @@
 use super::registry::ToolRegistry;
 use crate::config::MarkdownFlavor;
 use crate::rule::{LintWarning, Severity};
-use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
+use crate::utils::rumdl_parser_options;
+use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
 
 /// Special built-in tool name for rumdl's own markdown linting.
 /// When this tool is configured for markdown blocks, the processor skips
@@ -307,7 +308,7 @@
         let mut blocks = Vec::new();
         let mut current_block: Option<FencedCodeBlockBuilder> = None;
 
-        let options = Options::all();
+        let options = rumdl_parser_options();
         let parser = Parser::new_ext(content, options).into_offset_iter();
 
         let lines: Vec<&str> = content.lines().collect();
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/src/lint_context/element_parsers.rs 
new/rumdl-0.1.57/src/lint_context/element_parsers.rs
--- old/rumdl-0.1.54/src/lint_context/element_parsers.rs        2026-03-18 
17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/src/lint_context/element_parsers.rs        2026-03-20 
13:26:48.000000000 +0100
@@ -3,6 +3,7 @@
 use crate::utils::mkdocs_admonitions;
 use crate::utils::mkdocs_tabs;
 use crate::utils::regex_cache::URL_SIMPLE_REGEX;
+use crate::utils::rumdl_parser_options;
 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
 use regex::Regex;
 use std::sync::LazyLock;
@@ -569,7 +570,7 @@
     use crate::utils::blockquote::effective_indent_in_blockquote;
 
     let mut lazy_lines = Vec::new();
-    let parser = Parser::new_ext(content, Options::all());
+    let parser = Parser::new_ext(content, rumdl_parser_options());
 
     // Stack of (expected_indent_within_context, blockquote_level) for nested 
items
     let mut item_stack: Vec<(usize, usize)> = vec![];
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/rumdl-0.1.54/src/rules/md041_first_line_heading/mod.rs 
new/rumdl-0.1.57/src/rules/md041_first_line_heading/mod.rs
--- old/rumdl-0.1.54/src/rules/md041_first_line_heading/mod.rs  2026-03-18 
17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/src/rules/md041_first_line_heading/mod.rs  2026-03-20 
13:26:48.000000000 +0100
@@ -127,6 +127,37 @@
         false
     }
 
+    /// Find the first content line index (0-indexed) in the document.
+    ///
+    /// Skips front matter, blank lines, HTML/MDX comments, ESM blocks,
+    /// kramdown extensions, MkDocs anchors, reference definitions, and badges.
+    /// Used by both check() and fix() to ensure consistent behavior.
+    fn first_content_line_idx(ctx: &crate::lint_context::LintContext) -> 
Option<usize> {
+        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
+
+        for (idx, line_info) in ctx.lines.iter().enumerate() {
+            if line_info.in_front_matter
+                || line_info.is_blank
+                || line_info.in_esm_block
+                || line_info.in_html_comment
+                || line_info.in_mdx_comment
+                || line_info.in_kramdown_extension_block
+                || line_info.is_kramdown_block_ial
+            {
+                continue;
+            }
+            let line_content = line_info.content(ctx.content);
+            if is_mkdocs && is_mkdocs_anchor_line(line_content) {
+                continue;
+            }
+            if Self::is_non_content_line(line_content) {
+                continue;
+            }
+            return Some(idx);
+        }
+        None
+    }
+
     /// Check if a line consists only of badge/shield images
     /// Common patterns:
     /// - `![badge](url)`
@@ -385,6 +416,7 @@
             // Preamble: invisible/structural tokens that don't count as 
content
             let is_preamble = trimmed.is_empty()
                 || line_info.in_html_comment
+                || line_info.in_mdx_comment
                 || line_info.in_html_block
                 || Self::is_non_content_line(line_content)
                 || (is_mkdocs && is_mkdocs_anchor_line(line_content))
@@ -501,53 +533,9 @@
             return Ok(warnings);
         }
 
-        // Find the first non-blank line after front matter using cached info
-        let mut first_content_line_num = None;
-        let mut skip_lines = 0;
-
-        // Skip front matter (YAML, TOML, JSON, malformed)
-        for line_info in &ctx.lines {
-            if line_info.in_front_matter {
-                skip_lines += 1;
-            } else {
-                break;
-            }
-        }
-
-        // Check if we're in MkDocs flavor
-        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
-
-        for (line_num, line_info) in 
ctx.lines.iter().enumerate().skip(skip_lines) {
-            let line_content = line_info.content(ctx.content);
-            let trimmed = line_content.trim();
-            // Skip ESM blocks in MDX files (import/export statements)
-            if line_info.in_esm_block {
-                continue;
-            }
-            // Skip HTML comments - they are non-visible and should not affect 
MD041
-            if line_info.in_html_comment {
-                continue;
-            }
-            // Skip MkDocs anchor lines (empty link with attr_list) when in 
MkDocs flavor
-            if is_mkdocs && is_mkdocs_anchor_line(line_content) {
-                continue;
-            }
-            // Skip kramdown extension blocks and block IALs (preamble 
detection)
-            if line_info.in_kramdown_extension_block || 
line_info.is_kramdown_block_ial {
-                continue;
-            }
-            if !trimmed.is_empty() && !Self::is_non_content_line(line_content) 
{
-                first_content_line_num = Some(line_num);
-                break;
-            }
-        }
-
-        if first_content_line_num.is_none() {
-            // No non-blank lines after front matter
+        let Some(first_line_idx) = Self::first_content_line_idx(ctx) else {
             return Ok(warnings);
-        }
-
-        let first_line_idx = first_content_line_num.unwrap();
+        };
 
         // Check if the first non-blank line is a heading of the required level
         let first_line_info = &ctx.lines[first_line_idx];
@@ -642,13 +630,10 @@
             return Ok(ctx.content.to_string());
         }
 
-        // Respect inline disable comments
-        let first_content_line = ctx
-            .lines
-            .iter()
-            .enumerate()
-            .find(|(_, li)| !li.in_front_matter && !li.is_blank)
-            .map(|(i, _)| i + 1) // 1-indexed
+        // Respect inline disable comments — use the same first-content-line
+        // logic as check() so both paths agree on which line to check.
+        let first_content_line = Self::first_content_line_idx(ctx)
+            .map(|i| i + 1) // 1-indexed
             .unwrap_or(1);
         if ctx.inline_config().is_rule_disabled(self.name(), 
first_content_line) {
             return Ok(ctx.content.to_string());
@@ -2438,4 +2423,104 @@
 
         assert_eq!(patched, fixed, "Applying Fix directly should match fix() 
output");
     }
+
+    #[test]
+    fn test_mdx_disable_on_line_1_no_heading() {
+        // The exact user scenario from issue #538:
+        // MDX disable comment on line 1, NO heading anywhere.
+        // The disable is the ONLY reason MD041 should not fire.
+        let content = "{/* <!-- rumdl-disable MD041 MD034 --> 
*/}\n<Note>\nThis documentation is linted with http://rumdl.dev/\n</Note>";
+        let ctx = LintContext::new(content, 
crate::config::MarkdownFlavor::MDX, None);
+
+        // check() should produce a warning on line 2 (<Note> is the first 
content line)
+        let rule = MD041FirstLineHeading::default();
+        let warnings = rule.check(&ctx).unwrap();
+        // The rule itself produces a warning, but the engine filters it via 
inline config.
+        // MD041's check() doesn't filter inline config itself — the engine 
does.
+        // What matters is that the warning is on line 2 (not line 1), so the 
engine
+        // can see the disable is active at line 2 and suppress it.
+        if !warnings.is_empty() {
+            assert_eq!(
+                warnings[0].line, 2,
+                "Warning must be on line 2 (first content line after MDX 
comment), not line 1"
+            );
+        }
+    }
+
+    #[test]
+    fn test_mdx_disable_fix_returns_unchanged() {
+        // fix() should return content unchanged when MDX disable is active
+        let content = "{/* <!-- rumdl-disable MD041 --> 
*/}\n<Note>\nContent\n</Note>";
+        let ctx = LintContext::new(content, 
crate::config::MarkdownFlavor::MDX, None);
+        let rule = MD041FirstLineHeading {
+            fix_enabled: true,
+            ..MD041FirstLineHeading::default()
+        };
+        let result = rule.fix(&ctx).unwrap();
+        assert_eq!(
+            result, content,
+            "fix() should not modify content when MD041 is disabled via MDX 
comment"
+        );
+    }
+
+    #[test]
+    fn test_mdx_comment_without_disable_heading_on_next_line() {
+        let rule = MD041FirstLineHeading::default();
+
+        // MDX comment (not a disable directive) on line 1, heading on line 2
+        let content = "{/* Some MDX comment */}\n# My Document\n\nContent.";
+        let ctx = LintContext::new(content, 
crate::config::MarkdownFlavor::MDX, None);
+        let result = rule.check(&ctx).unwrap();
+        assert!(
+            result.is_empty(),
+            "MDX comment is preamble; heading on next line should satisfy 
MD041"
+        );
+    }
+
+    #[test]
+    fn test_mdx_comment_without_heading_triggers_warning() {
+        let rule = MD041FirstLineHeading::default();
+
+        // MDX comment on line 1, non-heading content on line 2
+        let content = "{/* Some MDX comment */}\nThis is not a 
heading\n\nContent.";
+        let ctx = LintContext::new(content, 
crate::config::MarkdownFlavor::MDX, None);
+        let result = rule.check(&ctx).unwrap();
+        assert_eq!(
+            result.len(),
+            1,
+            "MDX comment followed by non-heading should trigger MD041"
+        );
+        assert_eq!(
+            result[0].line, 2,
+            "Warning should be on line 2 (the first content line after MDX 
comment)"
+        );
+    }
+
+    #[test]
+    fn test_multiline_mdx_comment_followed_by_heading() {
+        let rule = MD041FirstLineHeading::default();
+
+        // Multi-line MDX comment followed by heading
+        let content = "{/*\nSome multi-line\nMDX comment\n*/}\n# My 
Document\n\nContent.";
+        let ctx = LintContext::new(content, 
crate::config::MarkdownFlavor::MDX, None);
+        let result = rule.check(&ctx).unwrap();
+        assert!(
+            result.is_empty(),
+            "Multi-line MDX comment should be preamble; heading after it 
satisfies MD041"
+        );
+    }
+
+    #[test]
+    fn test_html_comment_still_works_as_preamble_regression() {
+        let rule = MD041FirstLineHeading::default();
+
+        // Plain HTML comment on line 1, heading on line 2
+        let content = "<!-- Some comment -->\n# My Document\n\nContent.";
+        let ctx = LintContext::new(content, 
crate::config::MarkdownFlavor::Standard, None);
+        let result = rule.check(&ctx).unwrap();
+        assert!(
+            result.is_empty(),
+            "HTML comment should still be treated as preamble (regression 
test)"
+        );
+    }
 }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/rumdl-0.1.54/src/rules/md057_existing_relative_links/md057_config.rs 
new/rumdl-0.1.57/src/rules/md057_existing_relative_links/md057_config.rs
--- old/rumdl-0.1.54/src/rules/md057_existing_relative_links/md057_config.rs    
2026-03-18 17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/src/rules/md057_existing_relative_links/md057_config.rs    
2026-03-20 13:26:48.000000000 +0100
@@ -32,6 +32,24 @@
     /// and suggests the shorter equivalent `file.md`.
     #[serde(alias = "compact_paths")]
     pub compact_paths: bool,
+
+    /// Additional directories to search when a relative link is not found
+    /// relative to the file's directory.
+    ///
+    /// Paths are resolved relative to the project root (where `.rumdl.toml` or
+    /// `pyproject.toml` is found), or relative to the current working 
directory.
+    ///
+    /// For Obsidian users: the attachment folder is auto-detected from
+    /// `.obsidian/app.json` when `flavor = "obsidian"` is set, so this option
+    /// is typically not needed. Use it for custom setups or non-Obsidian 
tools.
+    ///
+    /// Example:
+    /// ```toml
+    /// [MD057]
+    /// search-paths = ["assets", "images", "attachments"]
+    /// ```
+    #[serde(alias = "search_paths")]
+    pub search_paths: Vec<String>,
 }
 
 impl RuleConfig for MD057Config {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/rumdl-0.1.54/src/rules/md057_existing_relative_links.rs 
new/rumdl-0.1.57/src/rules/md057_existing_relative_links.rs
--- old/rumdl-0.1.54/src/rules/md057_existing_relative_links.rs 2026-03-18 
17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/src/rules/md057_existing_relative_links.rs 2026-03-20 
13:26:48.000000000 +0100
@@ -17,6 +17,7 @@
 mod md057_config;
 use crate::rule_config_serde::RuleConfig;
 use crate::utils::mkdocs_config::resolve_docs_dir;
+use crate::utils::obsidian_config::resolve_attachment_folder;
 pub use md057_config::{AbsoluteLinksOption, MD057Config};
 
 // Thread-safe cache for file existence checks to avoid redundant filesystem 
operations
@@ -115,6 +116,8 @@
     base_path: Arc<Mutex<Option<PathBuf>>>,
     /// Configuration for the rule
     config: MD057Config,
+    /// Markdown flavor (used for Obsidian attachment folder auto-detection)
+    flavor: crate::config::MarkdownFlavor,
 }
 
 impl Default for MD057ExistingRelativeLinks {
@@ -122,6 +125,7 @@
         Self {
             base_path: Arc::new(Mutex::new(None)),
             config: MD057Config::default(),
+            flavor: crate::config::MarkdownFlavor::default(),
         }
     }
 }
@@ -151,9 +155,17 @@
         Self {
             base_path: Arc::new(Mutex::new(None)),
             config,
+            flavor: crate::config::MarkdownFlavor::default(),
         }
     }
 
+    /// Set the markdown flavor for Obsidian attachment auto-detection
+    #[cfg(test)]
+    fn with_flavor(mut self, flavor: crate::config::MarkdownFlavor) -> Self {
+        self.flavor = flavor;
+        self
+    }
+
     /// Check if a URL is external or should be skipped for validation.
     ///
     /// Returns `true` (skip validation) for:
@@ -283,6 +295,50 @@
         base_path.join(link)
     }
 
+    /// Compute additional search paths for fallback link resolution.
+    ///
+    /// Combines Obsidian attachment folder auto-detection (when flavor is 
Obsidian)
+    /// with explicitly configured `search-paths`.
+    fn compute_search_paths(
+        &self,
+        flavor: crate::config::MarkdownFlavor,
+        source_file: Option<&Path>,
+        base_path: &Path,
+    ) -> Vec<PathBuf> {
+        let mut paths = Vec::new();
+
+        // Auto-detect Obsidian attachment folder
+        if flavor == crate::config::MarkdownFlavor::Obsidian
+            && let Some(attachment_dir) = 
resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
+            && attachment_dir != *base_path
+        {
+            paths.push(attachment_dir);
+        }
+
+        // Add explicitly configured search paths
+        for search_path in &self.config.search_paths {
+            let resolved = if Path::new(search_path).is_absolute() {
+                PathBuf::from(search_path)
+            } else {
+                // Resolve relative to CWD (project root)
+                CURRENT_DIR.join(search_path)
+            };
+            if resolved != *base_path && !paths.contains(&resolved) {
+                paths.push(resolved);
+            }
+        }
+
+        paths
+    }
+
+    /// Check if a link target exists in any of the additional search paths.
+    fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> 
bool {
+        search_paths.iter().any(|dir| {
+            let candidate = dir.join(decoded_path);
+            file_exists_or_markdown_extension(&candidate)
+        })
+    }
+
     /// Check if a relative link can be compacted and return the simplified 
form.
     ///
     /// Returns `None` if compact-paths is disabled, the link has no traversal,
@@ -439,6 +495,9 @@
             return Ok(warnings);
         };
 
+        // Compute additional search paths for fallback link resolution
+        let extra_search_paths = self.compute_search_paths(ctx.flavor, 
ctx.source_file.as_deref(), &base_path);
+
         // Use LintContext links instead of expensive regex parsing
         if !ctx.links.is_empty() {
             // Use LineIndex for correct position calculation across all line 
ending types
@@ -626,6 +685,11 @@
                             continue; // Markdown source exists, link is valid
                         }
 
+                        // Try additional search paths (Obsidian attachment 
folder, configured paths)
+                        if Self::exists_in_search_paths(&decoded_path, 
&extra_search_paths) {
+                            continue;
+                        }
+
                         // File doesn't exist and no source file found
                         // Use actual URL position from regex capture group
                         // Note: capture group positions are absolute within 
the line string
@@ -762,6 +826,11 @@
                 continue; // Markdown source exists, link is valid
             }
 
+            // Try additional search paths (Obsidian attachment folder, 
configured paths)
+            if Self::exists_in_search_paths(&decoded_path, 
&extra_search_paths) {
+                continue;
+            }
+
             // File doesn't exist and no source file found
             // Images already have correct position from parser
             warnings.push(LintWarning {
@@ -889,6 +958,11 @@
                 continue; // Markdown source exists, link is valid
             }
 
+            // Try additional search paths (Obsidian attachment folder, 
configured paths)
+            if Self::exists_in_search_paths(&decoded_path, 
&extra_search_paths) {
+                continue;
+            }
+
             // File doesn't exist and no source file found
             // Calculate column position: find URL within the line
             let line_idx = ref_def.line - 1;
@@ -968,7 +1042,9 @@
         Self: Sized,
     {
         let rule_config = 
crate::rule_config_serde::load_rule_config::<MD057Config>(config);
-        Box::new(Self::from_config_struct(rule_config))
+        let mut rule = Self::from_config_struct(rule_config);
+        rule.flavor = config.global.flavor;
+        Box::new(rule)
     }
 
     fn cross_file_scope(&self) -> CrossFileScope {
@@ -994,6 +1070,10 @@
         // Get the directory containing this file for resolving relative links
         let file_dir = file_path.parent();
 
+        // Compute additional search paths for fallback link resolution
+        let base_path = file_dir.map(|d| d.to_path_buf()).unwrap_or_else(|| 
CURRENT_DIR.clone());
+        let extra_search_paths = self.compute_search_paths(self.flavor, 
Some(file_path), &base_path);
+
         for cross_link in &file_index.cross_file_links {
             // URL-decode the path for filesystem operations
             // The stored path is URL-encoded (e.g., "%F0%9F%91%A4" for emoji 
👤)
@@ -1036,7 +1116,7 @@
                     false
                 };
 
-                if !has_md_source {
+                if !has_md_source && 
!Self::exists_in_search_paths(&decoded_target, &extra_search_paths) {
                     warnings.push(LintWarning {
                         rule_name: Some(self.name().to_string()),
                         line: cross_link.line,
@@ -2883,4 +2963,437 @@
             "Warning should include the reference path"
         );
     }
+
+    #[test]
+    fn test_search_paths_inline_link() {
+        let temp_dir = tempdir().unwrap();
+        let base_path = temp_dir.path();
+
+        // Create an "assets" directory with an image
+        let assets_dir = base_path.join("assets");
+        std::fs::create_dir_all(&assets_dir).unwrap();
+        std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
+
+        let config = MD057Config {
+            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
+            ..Default::default()
+        };
+        let rule = 
MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
+
+        let content = "# Test\n\n[Photo](photo.png)\n";
+        let ctx = crate::lint_context::LintContext::new(content, 
crate::config::MarkdownFlavor::Standard, None);
+        let result = rule.check(&ctx).unwrap();
+
+        assert!(
+            result.is_empty(),
+            "Should find photo.png via search-paths. Got: {result:?}"
+        );
+    }
+
+    #[test]
+    fn test_search_paths_image() {
+        let temp_dir = tempdir().unwrap();
+        let base_path = temp_dir.path();
+
+        let assets_dir = base_path.join("attachments");
+        std::fs::create_dir_all(&assets_dir).unwrap();
+        std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
+
+        let config = MD057Config {
+            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
+            ..Default::default()
+        };
+        let rule = 
MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
+
+        let content = "# Test\n\n![Diagram](diagram.svg)\n";
+        let ctx = crate::lint_context::LintContext::new(content, 
crate::config::MarkdownFlavor::Standard, None);
+        let result = rule.check(&ctx).unwrap();
+
+        assert!(
+            result.is_empty(),
+            "Should find diagram.svg via search-paths. Got: {result:?}"
+        );
+    }
+
+    #[test]
+    fn test_search_paths_reference_definition() {
+        let temp_dir = tempdir().unwrap();
+        let base_path = temp_dir.path();
+
+        let assets_dir = base_path.join("images");
+        std::fs::create_dir_all(&assets_dir).unwrap();
+        std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
+
+        let config = MD057Config {
+            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
+            ..Default::default()
+        };
+        let rule = 
MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
+
+        let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
+        let ctx = crate::lint_context::LintContext::new(content, 
crate::config::MarkdownFlavor::Standard, None);
+        let result = rule.check(&ctx).unwrap();
+
+        assert!(
+            result.is_empty(),
+            "Should find logo.png via search-paths in reference definition. 
Got: {result:?}"
+        );
+    }
+
+    #[test]
+    fn test_search_paths_still_warns_when_truly_missing() {
+        let temp_dir = tempdir().unwrap();
+        let base_path = temp_dir.path();
+
+        let assets_dir = base_path.join("assets");
+        std::fs::create_dir_all(&assets_dir).unwrap();
+
+        let config = MD057Config {
+            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
+            ..Default::default()
+        };
+        let rule = 
MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
+
+        let content = "# Test\n\n![Missing](nonexistent.png)\n";
+        let ctx = crate::lint_context::LintContext::new(content, 
crate::config::MarkdownFlavor::Standard, None);
+        let result = rule.check(&ctx).unwrap();
+
+        assert_eq!(
+            result.len(),
+            1,
+            "Should still warn when file doesn't exist in any search path. 
Got: {result:?}"
+        );
+    }
+
+    #[test]
+    fn test_search_paths_nonexistent_directory() {
+        let temp_dir = tempdir().unwrap();
+        let base_path = temp_dir.path();
+
+        let config = MD057Config {
+            search_paths: 
vec!["/nonexistent/path/that/does/not/exist".to_string()],
+            ..Default::default()
+        };
+        let rule = 
MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
+
+        let content = "# Test\n\n![Missing](photo.png)\n";
+        let ctx = crate::lint_context::LintContext::new(content, 
crate::config::MarkdownFlavor::Standard, None);
+        let result = rule.check(&ctx).unwrap();
+
+        assert_eq!(
+            result.len(),
+            1,
+            "Nonexistent search path should not cause errors, just not find 
the file. Got: {result:?}"
+        );
+    }
+
+    #[test]
+    fn test_obsidian_attachment_folder_named() {
+        let temp_dir = tempdir().unwrap();
+        let vault = temp_dir.path().join("vault");
+        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
+        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
+        std::fs::create_dir_all(vault.join("notes")).unwrap();
+
+        std::fs::write(
+            vault.join(".obsidian/app.json"),
+            r#"{"attachmentFolderPath": "Attachments"}"#,
+        )
+        .unwrap();
+        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
+
+        let notes_dir = vault.join("notes");
+        let source_file = notes_dir.join("test.md");
+        std::fs::write(&source_file, "# 
Test\n\n![Photo](photo.png)\n").unwrap();
+
+        let rule = 
MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
+
+        let content = "# Test\n\n![Photo](photo.png)\n";
+        let ctx =
+            crate::lint_context::LintContext::new(content, 
crate::config::MarkdownFlavor::Obsidian, Some(source_file));
+        let result = rule.check(&ctx).unwrap();
+
+        assert!(
+            result.is_empty(),
+            "Obsidian attachment folder should resolve photo.png. Got: 
{result:?}"
+        );
+    }
+
+    #[test]
+    fn test_obsidian_attachment_same_folder_as_file() {
+        let temp_dir = tempdir().unwrap();
+        let vault = temp_dir.path().join("vault-rf");
+        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
+        std::fs::create_dir_all(vault.join("notes")).unwrap();
+
+        std::fs::write(vault.join(".obsidian/app.json"), 
r#"{"attachmentFolderPath": "./"}"#).unwrap();
+
+        // Image in the same directory as the file — default behavior, no 
extra search needed
+        let notes_dir = vault.join("notes");
+        let source_file = notes_dir.join("test.md");
+        std::fs::write(&source_file, "placeholder").unwrap();
+        std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
+
+        let rule = 
MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
+
+        let content = "# Test\n\n![Photo](photo.png)\n";
+        let ctx =
+            crate::lint_context::LintContext::new(content, 
crate::config::MarkdownFlavor::Obsidian, Some(source_file));
+        let result = rule.check(&ctx).unwrap();
+
+        assert!(
+            result.is_empty(),
+            "'./' attachment mode resolves to same folder — should work by 
default. Got: {result:?}"
+        );
+    }
+
+    #[test]
+    fn test_obsidian_not_triggered_without_obsidian_flavor() {
+        let temp_dir = tempdir().unwrap();
+        let vault = temp_dir.path().join("vault-nf");
+        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
+        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
+        std::fs::create_dir_all(vault.join("notes")).unwrap();
+
+        std::fs::write(
+            vault.join(".obsidian/app.json"),
+            r#"{"attachmentFolderPath": "Attachments"}"#,
+        )
+        .unwrap();
+        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
+
+        let notes_dir = vault.join("notes");
+        let source_file = notes_dir.join("test.md");
+        std::fs::write(&source_file, "placeholder").unwrap();
+
+        let rule = 
MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
+
+        let content = "# Test\n\n![Photo](photo.png)\n";
+        // Standard flavor — NOT Obsidian
+        let ctx =
+            crate::lint_context::LintContext::new(content, 
crate::config::MarkdownFlavor::Standard, Some(source_file));
+        let result = rule.check(&ctx).unwrap();
+
+        assert_eq!(
+            result.len(),
+            1,
+            "Without Obsidian flavor, attachment folder should not be 
auto-detected. Got: {result:?}"
+        );
+    }
+
+    #[test]
+    fn test_search_paths_combined_with_obsidian() {
+        let temp_dir = tempdir().unwrap();
+        let vault = temp_dir.path().join("vault-combo");
+        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
+        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
+        std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
+        std::fs::create_dir_all(vault.join("notes")).unwrap();
+
+        std::fs::write(
+            vault.join(".obsidian/app.json"),
+            r#"{"attachmentFolderPath": "Attachments"}"#,
+        )
+        .unwrap();
+        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
+        std::fs::write(vault.join("extra-assets/diagram.svg"), 
"fake").unwrap();
+
+        let notes_dir = vault.join("notes");
+        let source_file = notes_dir.join("test.md");
+        std::fs::write(&source_file, "placeholder").unwrap();
+
+        let extra_assets_dir = vault.join("extra-assets");
+        let config = MD057Config {
+            search_paths: 
vec![extra_assets_dir.to_string_lossy().into_owned()],
+            ..Default::default()
+        };
+        let rule = 
MD057ExistingRelativeLinks::from_config_struct(config).with_path(&notes_dir);
+
+        // Both links should resolve: photo.png via Obsidian, diagram.svg via 
search-paths
+        let content = "# 
Test\n\n![Photo](photo.png)\n\n![Diagram](diagram.svg)\n";
+        let ctx =
+            crate::lint_context::LintContext::new(content, 
crate::config::MarkdownFlavor::Obsidian, Some(source_file));
+        let result = rule.check(&ctx).unwrap();
+
+        assert!(
+            result.is_empty(),
+            "Both Obsidian attachment and search-paths should resolve. Got: 
{result:?}"
+        );
+    }
+
+    #[test]
+    fn test_obsidian_attachment_subfolder_under_file() {
+        let temp_dir = tempdir().unwrap();
+        let vault = temp_dir.path().join("vault-sub");
+        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
+        std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
+
+        std::fs::write(
+            vault.join(".obsidian/app.json"),
+            r#"{"attachmentFolderPath": "./assets"}"#,
+        )
+        .unwrap();
+        std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
+
+        let notes_dir = vault.join("notes");
+        let source_file = notes_dir.join("test.md");
+        std::fs::write(&source_file, "placeholder").unwrap();
+
+        let rule = 
MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
+
+        let content = "# Test\n\n![Photo](photo.png)\n";
+        let ctx =
+            crate::lint_context::LintContext::new(content, 
crate::config::MarkdownFlavor::Obsidian, Some(source_file));
+        let result = rule.check(&ctx).unwrap();
+
+        assert!(
+            result.is_empty(),
+            "Obsidian './assets' mode should find photo.png in 
<file-dir>/assets/. Got: {result:?}"
+        );
+    }
+
+    #[test]
+    fn test_obsidian_attachment_vault_root() {
+        let temp_dir = tempdir().unwrap();
+        let vault = temp_dir.path().join("vault-root");
+        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
+        std::fs::create_dir_all(vault.join("notes")).unwrap();
+
+        // Empty string = vault root
+        std::fs::write(vault.join(".obsidian/app.json"), 
r#"{"attachmentFolderPath": ""}"#).unwrap();
+        std::fs::write(vault.join("photo.png"), "fake").unwrap();
+
+        let notes_dir = vault.join("notes");
+        let source_file = notes_dir.join("test.md");
+        std::fs::write(&source_file, "placeholder").unwrap();
+
+        let rule = 
MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
+
+        let content = "# Test\n\n![Photo](photo.png)\n";
+        let ctx =
+            crate::lint_context::LintContext::new(content, 
crate::config::MarkdownFlavor::Obsidian, Some(source_file));
+        let result = rule.check(&ctx).unwrap();
+
+        assert!(
+            result.is_empty(),
+            "Obsidian vault-root mode should find photo.png at vault root. 
Got: {result:?}"
+        );
+    }
+
+    #[test]
+    fn test_search_paths_multiple_directories() {
+        let temp_dir = tempdir().unwrap();
+        let base_path = temp_dir.path();
+
+        let dir_a = base_path.join("dir-a");
+        let dir_b = base_path.join("dir-b");
+        std::fs::create_dir_all(&dir_a).unwrap();
+        std::fs::create_dir_all(&dir_b).unwrap();
+        std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
+        std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
+
+        let config = MD057Config {
+            search_paths: vec![
+                dir_a.to_string_lossy().into_owned(),
+                dir_b.to_string_lossy().into_owned(),
+            ],
+            ..Default::default()
+        };
+        let rule = 
MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
+
+        let content = "# Test\n\n![A](alpha.png)\n\n![B](beta.png)\n";
+        let ctx = crate::lint_context::LintContext::new(content, 
crate::config::MarkdownFlavor::Standard, None);
+        let result = rule.check(&ctx).unwrap();
+
+        assert!(
+            result.is_empty(),
+            "Should find files across multiple search paths. Got: {result:?}"
+        );
+    }
+
+    #[test]
+    fn test_cross_file_check_with_search_paths() {
+        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, 
WorkspaceIndex};
+
+        let temp_dir = tempdir().unwrap();
+        let base_path = temp_dir.path();
+
+        // Create docs directory with a markdown target in a search path
+        let docs_dir = base_path.join("docs");
+        std::fs::create_dir_all(&docs_dir).unwrap();
+        std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap();
+
+        let config = MD057Config {
+            search_paths: vec![docs_dir.to_string_lossy().into_owned()],
+            ..Default::default()
+        };
+        let rule = 
MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
+
+        let file_path = base_path.join("README.md");
+        std::fs::write(&file_path, "# Readme\n").unwrap();
+
+        let mut file_index = FileIndex::default();
+        file_index.cross_file_links.push(CrossFileLinkIndex {
+            target_path: "guide.md".to_string(),
+            fragment: String::new(),
+            line: 3,
+            column: 1,
+        });
+
+        let workspace_index = WorkspaceIndex::new();
+
+        let result = rule
+            .cross_file_check(&file_path, &file_index, &workspace_index)
+            .unwrap();
+
+        assert!(
+            result.is_empty(),
+            "cross_file_check should find guide.md via search-paths. Got: 
{result:?}"
+        );
+    }
+
+    #[test]
+    fn test_cross_file_check_with_obsidian_flavor() {
+        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, 
WorkspaceIndex};
+
+        let temp_dir = tempdir().unwrap();
+        let vault = temp_dir.path().join("vault-xf");
+        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
+        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
+        std::fs::create_dir_all(vault.join("notes")).unwrap();
+
+        std::fs::write(
+            vault.join(".obsidian/app.json"),
+            r#"{"attachmentFolderPath": "Attachments"}"#,
+        )
+        .unwrap();
+        std::fs::write(vault.join("Attachments/ref.md"), "# 
Reference\n").unwrap();
+
+        let notes_dir = vault.join("notes");
+        let file_path = notes_dir.join("test.md");
+        std::fs::write(&file_path, "placeholder").unwrap();
+
+        let rule = 
MD057ExistingRelativeLinks::from_config_struct(MD057Config::default())
+            .with_path(&notes_dir)
+            .with_flavor(crate::config::MarkdownFlavor::Obsidian);
+
+        let mut file_index = FileIndex::default();
+        file_index.cross_file_links.push(CrossFileLinkIndex {
+            target_path: "ref.md".to_string(),
+            fragment: String::new(),
+            line: 3,
+            column: 1,
+        });
+
+        let workspace_index = WorkspaceIndex::new();
+
+        let result = rule
+            .cross_file_check(&file_path, &file_index, &workspace_index)
+            .unwrap();
+
+        assert!(
+            result.is_empty(),
+            "cross_file_check should find ref.md via Obsidian attachment 
folder. Got: {result:?}"
+        );
+    }
 }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/src/utils/code_block_utils.rs 
new/rumdl-0.1.57/src/utils/code_block_utils.rs
--- old/rumdl-0.1.54/src/utils/code_block_utils.rs      2026-03-18 
17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/src/utils/code_block_utils.rs      2026-03-20 
13:26:48.000000000 +0100
@@ -8,7 +8,9 @@
 //! - Mixed fence types (tilde fence contains backticks as content)
 //! - Indented code blocks with proper list context handling
 
-use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
+use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
+
+use super::parser_options::rumdl_parser_options;
 
 /// Type alias for code block and span ranges: (code_blocks, code_spans)
 pub type CodeRanges = (Vec<(usize, usize)>, Vec<(usize, usize)>);
@@ -108,8 +110,7 @@
 
         let byte_to_line = |byte_offset: usize| -> usize { 
line_starts.partition_point(|&start| start <= byte_offset) };
 
-        // Use pulldown-cmark with all extensions for maximum compatibility
-        let options = Options::all();
+        let options = rumdl_parser_options();
         let parser = Parser::new_ext(content, options).into_offset_iter();
 
         for (event, range) in parser {
@@ -325,12 +326,12 @@
     /// Only detects fenced code blocks (``` or ~~~), not indented code blocks,
     /// since indented blocks don't have a language tag.
     pub fn detect_markdown_code_blocks(content: &str) -> 
Vec<MarkdownCodeBlock> {
-        use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, 
TagEnd};
+        use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
 
         let mut blocks = Vec::new();
         let mut current_block: Option<MarkdownCodeBlockBuilder> = None;
 
-        let options = Options::all();
+        let options = rumdl_parser_options();
         let parser = Parser::new_ext(content, options).into_offset_iter();
 
         for (event, range) in parser {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/src/utils/mod.rs 
new/rumdl-0.1.57/src/utils/mod.rs
--- old/rumdl-0.1.54/src/utils/mod.rs   2026-03-18 17:23:42.000000000 +0100
+++ new/rumdl-0.1.57/src/utils/mod.rs   2026-03-20 13:26:48.000000000 +0100
@@ -29,6 +29,8 @@
 pub mod mkdocs_tabs;
 pub mod mkdocs_test_utils;
 pub mod mkdocstrings_refs;
+pub mod obsidian_config;
+pub mod parser_options;
 pub mod pymdown_blocks;
 pub mod quarto_divs;
 pub mod range_utils;
@@ -47,6 +49,7 @@
     normalize_line_ending,
 };
 pub use markdown_elements::{ElementQuality, ElementType, MarkdownElement, 
MarkdownElements};
+pub use parser_options::rumdl_parser_options;
 pub use range_utils::LineIndex;
 
 /// Calculate the visual indentation width of a string, expanding tabs to 
spaces.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/src/utils/obsidian_config.rs 
new/rumdl-0.1.57/src/utils/obsidian_config.rs
--- old/rumdl-0.1.54/src/utils/obsidian_config.rs       1970-01-01 
01:00:00.000000000 +0100
+++ new/rumdl-0.1.57/src/utils/obsidian_config.rs       2026-03-20 
13:26:48.000000000 +0100
@@ -0,0 +1,245 @@
+//! Obsidian vault configuration utilities.
+//!
+//! Provides discovery and parsing of `.obsidian/app.json` files,
+//! with caching for efficient repeated lookups.
+//!
+//! Mirrors the pattern used by `mkdocs_config.rs` for MkDocs projects.
+
+use serde::Deserialize;
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::sync::{LazyLock, Mutex};
+
+/// Cache: canonicalized vault root -> resolved attachment folder (absolute)
+static ATTACHMENT_DIR_CACHE: LazyLock<Mutex<HashMap<PathBuf, 
AttachmentResolution>>> =
+    LazyLock::new(|| Mutex::new(HashMap::new()));
+
+/// Result of resolving the Obsidian attachment folder configuration.
+#[derive(Debug, Clone)]
+pub enum AttachmentResolution {
+    /// Absolute path to a fixed attachment folder (vault root or named folder)
+    Fixed(PathBuf),
+    /// Relative to each file's directory (`./ ` prefix in config)
+    RelativeToFile(String),
+}
+
+/// Minimal `.obsidian/app.json` structure for extracting attachment settings.
+#[derive(Debug, Deserialize)]
+struct ObsidianAppConfig {
+    #[serde(default, rename = "attachmentFolderPath")]
+    attachment_folder_path: String,
+}
+
+/// Find an Obsidian vault root by walking up from `start_path`.
+///
+/// Returns the vault root directory (parent of `.obsidian/`), or None if not 
found.
+pub fn find_obsidian_vault(start_path: &Path) -> Option<PathBuf> {
+    let mut current = if start_path.is_file() {
+        start_path.parent()?.to_path_buf()
+    } else {
+        start_path.to_path_buf()
+    };
+
+    loop {
+        let obsidian_dir = current.join(".obsidian");
+        if obsidian_dir.is_dir() {
+            return current.canonicalize().ok();
+        }
+
+        if !current.pop() {
+            break;
+        }
+    }
+
+    None
+}
+
+/// Resolve the attachment folder for a given file in an Obsidian vault.
+///
+/// Reads `.obsidian/app.json` to determine the `attachmentFolderPath` setting:
+/// - `""` (empty/absent): vault root
+/// - `"FolderName"`: `<vault-root>/FolderName/`
+/// - `"./"`: same folder as current file
+/// - `"./subfolder"`: `<file-dir>/subfolder/`
+///
+/// Results are cached by vault root path.
+///
+/// `start_path` should be the markdown file being checked or its parent 
directory.
+/// `file_dir` is the directory containing the file being checked (for `./` 
resolution).
+///
+/// Returns the absolute path to the attachment folder, or None if no vault is 
found.
+pub fn resolve_attachment_folder(start_path: &Path, file_dir: &Path) -> 
Option<PathBuf> {
+    let vault_root = find_obsidian_vault(start_path)?;
+
+    // Check cache first
+    if let Ok(cache) = ATTACHMENT_DIR_CACHE.lock()
+        && let Some(resolution) = cache.get(&vault_root)
+    {
+        return Some(match resolution {
+            AttachmentResolution::Fixed(path) => path.clone(),
+            AttachmentResolution::RelativeToFile(subfolder) => {
+                if subfolder.is_empty() {
+                    file_dir.to_path_buf()
+                } else {
+                    file_dir.join(subfolder)
+                }
+            }
+        });
+    }
+
+    // Parse .obsidian/app.json
+    let app_json_path = vault_root.join(".obsidian").join("app.json");
+    let attachment_folder_path = if app_json_path.exists() {
+        std::fs::read_to_string(&app_json_path)
+            .ok()
+            .and_then(|content| 
serde_json::from_str::<ObsidianAppConfig>(&content).ok())
+            .map(|config| config.attachment_folder_path)
+            .unwrap_or_default()
+    } else {
+        String::new()
+    };
+
+    // Resolve and cache
+    let resolution = if attachment_folder_path.is_empty() {
+        AttachmentResolution::Fixed(vault_root.clone())
+    } else if let Some(relative) = attachment_folder_path.strip_prefix("./") {
+        AttachmentResolution::RelativeToFile(relative.to_string())
+    } else {
+        AttachmentResolution::Fixed(vault_root.join(&attachment_folder_path))
+    };
+
+    let result = match &resolution {
+        AttachmentResolution::Fixed(path) => path.clone(),
+        AttachmentResolution::RelativeToFile(subfolder) => {
+            if subfolder.is_empty() {
+                file_dir.to_path_buf()
+            } else {
+                file_dir.join(subfolder)
+            }
+        }
+    };
+
+    if let Ok(mut cache) = ATTACHMENT_DIR_CACHE.lock() {
+        cache.insert(vault_root, resolution);
+    }
+
+    Some(result)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::fs;
+    use tempfile::tempdir;
+
+    #[test]
+    fn test_find_obsidian_vault() {
+        let temp = tempdir().unwrap();
+        let vault = temp.path().join("my-vault");
+        fs::create_dir_all(vault.join(".obsidian")).unwrap();
+        fs::create_dir_all(vault.join("notes/subfolder")).unwrap();
+
+        // From a file in the vault root
+        let result = find_obsidian_vault(&vault.join("test.md"));
+        assert!(result.is_some());
+
+        // From a nested subfolder
+        let result = 
find_obsidian_vault(&vault.join("notes/subfolder/deep.md"));
+        assert!(result.is_some());
+
+        // From outside the vault
+        let result = find_obsidian_vault(temp.path());
+        assert!(result.is_none());
+    }
+
+    #[test]
+    fn test_resolve_attachment_folder_vault_root() {
+        let temp = tempdir().unwrap();
+        let vault = temp.path().join("vault");
+        fs::create_dir_all(vault.join(".obsidian")).unwrap();
+        fs::write(vault.join(".obsidian/app.json"), 
r#"{"attachmentFolderPath": ""}"#).unwrap();
+
+        let file_dir = vault.join("notes");
+        fs::create_dir_all(&file_dir).unwrap();
+
+        let result = resolve_attachment_folder(&file_dir.join("test.md"), 
&file_dir);
+        assert!(result.is_some());
+        let resolved = result.unwrap();
+        assert_eq!(resolved.canonicalize().unwrap(), 
vault.canonicalize().unwrap());
+    }
+
+    #[test]
+    fn test_resolve_attachment_folder_named_folder() {
+        let temp = tempdir().unwrap();
+        let vault = temp.path().join("vault2");
+        fs::create_dir_all(vault.join(".obsidian")).unwrap();
+        fs::create_dir_all(vault.join("Attachments")).unwrap();
+        fs::write(
+            vault.join(".obsidian/app.json"),
+            r#"{"attachmentFolderPath": "Attachments"}"#,
+        )
+        .unwrap();
+
+        let file_dir = vault.join("notes");
+        fs::create_dir_all(&file_dir).unwrap();
+
+        let result = resolve_attachment_folder(&file_dir.join("test.md"), 
&file_dir);
+        assert!(result.is_some());
+        let resolved = result.unwrap();
+        assert!(resolved.ends_with("Attachments"));
+    }
+
+    #[test]
+    fn test_resolve_attachment_folder_relative_to_file() {
+        let temp = tempdir().unwrap();
+        let vault = temp.path().join("vault3");
+        fs::create_dir_all(vault.join(".obsidian")).unwrap();
+        fs::write(vault.join(".obsidian/app.json"), 
r#"{"attachmentFolderPath": "./"}"#).unwrap();
+
+        let file_dir = vault.join("notes");
+        fs::create_dir_all(&file_dir).unwrap();
+
+        let result = resolve_attachment_folder(&file_dir.join("test.md"), 
&file_dir);
+        assert!(result.is_some());
+        assert_eq!(result.unwrap(), file_dir);
+    }
+
+    #[test]
+    fn test_resolve_attachment_folder_subfolder_under_file() {
+        let temp = tempdir().unwrap();
+        let vault = temp.path().join("vault4");
+        fs::create_dir_all(vault.join(".obsidian")).unwrap();
+        fs::write(
+            vault.join(".obsidian/app.json"),
+            r#"{"attachmentFolderPath": "./assets"}"#,
+        )
+        .unwrap();
+
+        let file_dir = vault.join("notes");
+        fs::create_dir_all(&file_dir).unwrap();
+
+        let result = resolve_attachment_folder(&file_dir.join("test.md"), 
&file_dir);
+        assert!(result.is_some());
+        assert!(result.unwrap().ends_with("assets"));
+    }
+
+    #[test]
+    fn test_resolve_attachment_folder_no_app_json() {
+        let temp = tempdir().unwrap();
+        let vault = temp.path().join("vault5");
+        fs::create_dir_all(vault.join(".obsidian")).unwrap();
+        // No app.json - should default to vault root
+
+        let result = resolve_attachment_folder(&vault.join("test.md"), &vault);
+        assert!(result.is_some());
+        let resolved = result.unwrap();
+        assert_eq!(resolved.canonicalize().unwrap(), 
vault.canonicalize().unwrap());
+    }
+
+    #[test]
+    fn test_no_vault_returns_none() {
+        let temp = tempdir().unwrap();
+        let result = resolve_attachment_folder(&temp.path().join("test.md"), 
temp.path());
+        assert!(result.is_none());
+    }
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.1.54/src/utils/parser_options.rs 
new/rumdl-0.1.57/src/utils/parser_options.rs
--- old/rumdl-0.1.54/src/utils/parser_options.rs        1970-01-01 
01:00:00.000000000 +0100
+++ new/rumdl-0.1.57/src/utils/parser_options.rs        2026-03-20 
13:26:48.000000000 +0100
@@ -0,0 +1,28 @@
+use pulldown_cmark::Options;
+
+/// Standard pulldown-cmark options for rumdl parsing.
+///
+/// Uses an explicit allowlist rather than `Options::all()` to prevent
+/// future pulldown-cmark releases from silently changing parse behavior.
+///
+/// Notably excludes `ENABLE_YAML_STYLE_METADATA_BLOCKS` and
+/// `ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS` because rumdl handles
+/// front matter detection independently. These options cause pulldown-cmark
+/// to misinterpret `---` horizontal rules as metadata delimiters,
+/// corrupting code block detection across the entire document.
+pub fn rumdl_parser_options() -> Options {
+    let mut options = Options::empty();
+    options.insert(Options::ENABLE_TABLES);
+    options.insert(Options::ENABLE_OLD_FOOTNOTES);
+    options.insert(Options::ENABLE_STRIKETHROUGH);
+    options.insert(Options::ENABLE_TASKLISTS);
+    options.insert(Options::ENABLE_SMART_PUNCTUATION);
+    options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
+    options.insert(Options::ENABLE_MATH);
+    options.insert(Options::ENABLE_GFM);
+    options.insert(Options::ENABLE_DEFINITION_LIST);
+    options.insert(Options::ENABLE_SUPERSCRIPT);
+    options.insert(Options::ENABLE_SUBSCRIPT);
+    options.insert(Options::ENABLE_WIKILINKS);
+    options
+}

++++++ rumdl.obsinfo ++++++
--- /var/tmp/diff_new_pack.0Lz9Ue/_old  2026-03-20 21:27:32.012783592 +0100
+++ /var/tmp/diff_new_pack.0Lz9Ue/_new  2026-03-20 21:27:32.024784091 +0100
@@ -1,5 +1,5 @@
 name: rumdl
-version: 0.1.54
-mtime: 1773851022
-commit: 8eeed34c6f8f3bacc43f59a6a34f3c15dd594b20
+version: 0.1.57
+mtime: 1774009608
+commit: b52183a24937296947580d86711ecde803ba6e83
 

++++++ vendor.tar.zst ++++++
/work/SRC/openSUSE:Factory/rumdl/vendor.tar.zst 
/work/SRC/openSUSE:Factory/.rumdl.new.8177/vendor.tar.zst differ: char 7, line 1

Reply via email to