mkdocs-easylinks-plugin 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mkdocs_easylinks/__init__.py +3 -0
- mkdocs_easylinks/plugin.py +305 -0
- mkdocs_easylinks_plugin-0.1.1.dist-info/METADATA +292 -0
- mkdocs_easylinks_plugin-0.1.1.dist-info/RECORD +8 -0
- mkdocs_easylinks_plugin-0.1.1.dist-info/WHEEL +5 -0
- mkdocs_easylinks_plugin-0.1.1.dist-info/entry_points.txt +2 -0
- mkdocs_easylinks_plugin-0.1.1.dist-info/licenses/LICENSE +21 -0
- mkdocs_easylinks_plugin-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""MkDocs plugin for easy cross-references using only filenames."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import logging
|
|
6
|
+
import fnmatch
|
|
7
|
+
from typing import Dict, Optional, Tuple
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from mkdocs.config import config_options
|
|
10
|
+
from mkdocs.config.base import Config
|
|
11
|
+
from mkdocs.plugins import BasePlugin
|
|
12
|
+
from mkdocs.structure.files import Files
|
|
13
|
+
from mkdocs.structure.pages import Page
|
|
14
|
+
from mkdocs.config.defaults import MkDocsConfig
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("mkdocs.plugins.easylinks")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EasyLinksConfig(Config):
|
|
20
|
+
warn_on_missing = config_options.Type(bool, default=True)
|
|
21
|
+
warn_on_ambiguous = config_options.Type(bool, default=True)
|
|
22
|
+
ignore_files = config_options.Type(list, default=[])
|
|
23
|
+
exclude_dirs = config_options.Type(list, default=[])
|
|
24
|
+
show_stats = config_options.Type(bool, default=False)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
|
|
28
|
+
"""Plugin to resolve markdown links by filename only."""
|
|
29
|
+
|
|
30
|
+
_link_pattern = re.compile(r'(!)?\[([^\]]+)\]\(([^)]+)\)')
|
|
31
|
+
|
|
32
|
+
def __init__(self):
|
|
33
|
+
super().__init__()
|
|
34
|
+
self.file_map: Dict[str, str] = {}
|
|
35
|
+
self.ambiguous_files: Dict[str, list] = {}
|
|
36
|
+
# Statistics tracking
|
|
37
|
+
self.stats = {
|
|
38
|
+
"total_files_scanned": 0,
|
|
39
|
+
"files_indexed": 0,
|
|
40
|
+
"files_ignored": 0,
|
|
41
|
+
"links_processed": 0,
|
|
42
|
+
"links_resolved": 0,
|
|
43
|
+
"links_unresolved": 0,
|
|
44
|
+
"images_processed": 0,
|
|
45
|
+
"images_resolved": 0,
|
|
46
|
+
"images_unresolved": 0,
|
|
47
|
+
}
|
|
48
|
+
self.link_counts = defaultdict(int) # Track how many times each file is linked
|
|
49
|
+
|
|
50
|
+
def on_files(self, files: Files, *, config: MkDocsConfig) -> Files:
|
|
51
|
+
"""Build a mapping of filenames to their full paths."""
|
|
52
|
+
self.file_map = {}
|
|
53
|
+
self.ambiguous_files = {}
|
|
54
|
+
self.stats = {key: 0 for key in self.stats}
|
|
55
|
+
self.link_counts = defaultdict(int)
|
|
56
|
+
|
|
57
|
+
# Process all files (documentation pages, images, etc.)
|
|
58
|
+
for file in files:
|
|
59
|
+
self.stats["total_files_scanned"] += 1
|
|
60
|
+
filename = os.path.basename(file.src_path)
|
|
61
|
+
|
|
62
|
+
# Ignore files starting with a dot (hidden files)
|
|
63
|
+
if filename.startswith('.'):
|
|
64
|
+
self.stats["files_ignored"] += 1
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Check if file is in an excluded directory
|
|
68
|
+
if self._is_excluded_dir(file.src_path):
|
|
69
|
+
self.stats["files_ignored"] += 1
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
# Ignore files matching patterns in ignore_files
|
|
73
|
+
if self._should_ignore_file(filename):
|
|
74
|
+
self.stats["files_ignored"] += 1
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
if filename in self.file_map:
|
|
78
|
+
if filename not in self.ambiguous_files:
|
|
79
|
+
self.ambiguous_files[filename] = [self.file_map[filename]]
|
|
80
|
+
self.ambiguous_files[filename].append(file.src_path)
|
|
81
|
+
self.stats["files_indexed"] += 1
|
|
82
|
+
else:
|
|
83
|
+
self.file_map[filename] = file.src_path
|
|
84
|
+
self.stats["files_indexed"] += 1
|
|
85
|
+
|
|
86
|
+
# Warn about ambiguous files
|
|
87
|
+
if self.config["warn_on_ambiguous"] and self.ambiguous_files:
|
|
88
|
+
for filename, paths in self.ambiguous_files.items():
|
|
89
|
+
logger.warning(
|
|
90
|
+
f"easylinks: Ambiguous filename '{filename}' found in multiple locations:\n"
|
|
91
|
+
+ "\n".join(f" - {path}" for path in paths)
|
|
92
|
+
+ "\nLinks to this file will use the first occurrence. "
|
|
93
|
+
"Consider using full paths for disambiguation."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return files
|
|
97
|
+
|
|
98
|
+
def _is_excluded_dir(self, file_path: str) -> bool:
|
|
99
|
+
"""Check if a file is in an excluded directory."""
|
|
100
|
+
if not self.config["exclude_dirs"]:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
# Normalize the path
|
|
104
|
+
normalized_path = file_path.replace("\\", "/")
|
|
105
|
+
|
|
106
|
+
for excluded_dir in self.config["exclude_dirs"]:
|
|
107
|
+
# Normalize excluded dir
|
|
108
|
+
excluded = excluded_dir.rstrip("/") + "/"
|
|
109
|
+
# Check if file path starts with excluded directory
|
|
110
|
+
if normalized_path.startswith(excluded):
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
def _should_ignore_file(self, filename: str) -> bool:
|
|
116
|
+
"""Check if a filename matches any ignore pattern (supports glob patterns)."""
|
|
117
|
+
if not self.config["ignore_files"]:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
for pattern in self.config["ignore_files"]:
|
|
121
|
+
# Support both exact match and glob patterns
|
|
122
|
+
if fnmatch.fnmatch(filename, pattern):
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
def on_page_markdown(
|
|
128
|
+
self, markdown: str, *, page: Page, config: MkDocsConfig, files: Files
|
|
129
|
+
) -> str:
|
|
130
|
+
"""Replace simple filename links with full path links."""
|
|
131
|
+
return self._process_links(markdown, page)
|
|
132
|
+
|
|
133
|
+
def _process_links(self, markdown: str, page: Page) -> str:
|
|
134
|
+
"""Process markdown links and image links, replacing simple filenames with full paths."""
|
|
135
|
+
# Extract code fences and HTML comments before processing
|
|
136
|
+
markdown, protected_blocks = self._extract_protected_blocks(markdown)
|
|
137
|
+
|
|
138
|
+
def replace_link(match):
|
|
139
|
+
is_image = match.group(1) # Will be '!' for images, None for regular links
|
|
140
|
+
link_text = match.group(2)
|
|
141
|
+
link_url = match.group(3)
|
|
142
|
+
|
|
143
|
+
# Skip if it's an external link, anchor, or absolute path
|
|
144
|
+
if (link_url.startswith(('http://', 'https://', '//', '#', '/'))
|
|
145
|
+
or (':' in link_url and not link_url.startswith('file:'))):
|
|
146
|
+
return match.group(0)
|
|
147
|
+
|
|
148
|
+
# Extract anchor if present (only relevant for links, not images)
|
|
149
|
+
anchor = ""
|
|
150
|
+
if "#" in link_url:
|
|
151
|
+
link_url, anchor = link_url.split("#", 1)
|
|
152
|
+
anchor = "#" + anchor
|
|
153
|
+
|
|
154
|
+
# Check if this is just a filename (no path separators)
|
|
155
|
+
if "/" not in link_url and "\\" not in link_url:
|
|
156
|
+
# Track statistics
|
|
157
|
+
if is_image:
|
|
158
|
+
self.stats["images_processed"] += 1
|
|
159
|
+
else:
|
|
160
|
+
self.stats["links_processed"] += 1
|
|
161
|
+
|
|
162
|
+
resolved_path = self._resolve_filename(link_url)
|
|
163
|
+
if resolved_path:
|
|
164
|
+
# Warn if this filename is ambiguous
|
|
165
|
+
if self.config["warn_on_ambiguous"] and link_url in self.ambiguous_files:
|
|
166
|
+
all_paths = self.ambiguous_files[link_url]
|
|
167
|
+
logger.warning(
|
|
168
|
+
f"easylinks: Ambiguous filename '{link_url}' referred to in "
|
|
169
|
+
f"'{page.file.src_path}': exists at {all_paths}. "
|
|
170
|
+
f"Using '{resolved_path}'."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Track successful resolution
|
|
174
|
+
if is_image:
|
|
175
|
+
self.stats["images_resolved"] += 1
|
|
176
|
+
else:
|
|
177
|
+
self.stats["links_resolved"] += 1
|
|
178
|
+
# Count how many times each file is linked
|
|
179
|
+
self.link_counts[resolved_path] += 1
|
|
180
|
+
|
|
181
|
+
# Calculate relative path from current page to target
|
|
182
|
+
relative_path = self._get_relative_path(page.file.src_path, resolved_path)
|
|
183
|
+
# Reconstruct with or without the ! prefix
|
|
184
|
+
prefix = "!" if is_image else ""
|
|
185
|
+
return f"{prefix}[{link_text}]({relative_path}{anchor})"
|
|
186
|
+
else:
|
|
187
|
+
# Track unresolved links/images separately
|
|
188
|
+
if is_image:
|
|
189
|
+
self.stats["images_unresolved"] += 1
|
|
190
|
+
else:
|
|
191
|
+
self.stats["links_unresolved"] += 1
|
|
192
|
+
|
|
193
|
+
if self.config["warn_on_missing"]:
|
|
194
|
+
file_type = "image" if is_image else "file"
|
|
195
|
+
logger.warning(
|
|
196
|
+
f"easylinks: Could not resolve {file_type} link to '{link_url}' on page '{page.file.src_path}'"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return match.group(0)
|
|
200
|
+
|
|
201
|
+
markdown = self._link_pattern.sub(replace_link, markdown)
|
|
202
|
+
|
|
203
|
+
# Restore code fences and HTML comments
|
|
204
|
+
markdown = self._restore_protected_blocks(markdown, protected_blocks)
|
|
205
|
+
|
|
206
|
+
return markdown
|
|
207
|
+
|
|
208
|
+
def _extract_protected_blocks(self, markdown: str) -> Tuple[str, dict]:
|
|
209
|
+
"""Extract code fences and HTML comments, replacing them with placeholders.
|
|
210
|
+
|
|
211
|
+
Note: Only explicit fenced code blocks (``` or ~~~) are protected.
|
|
212
|
+
Indented content (like in MkDocs admonitions) is NOT protected and will
|
|
213
|
+
be processed normally. This is intentional to support MkDocs features.
|
|
214
|
+
"""
|
|
215
|
+
protected_blocks = {}
|
|
216
|
+
counter = 0
|
|
217
|
+
|
|
218
|
+
def make_placeholder(match):
|
|
219
|
+
nonlocal counter
|
|
220
|
+
placeholder = f"___PROTECTED_BLOCK_{counter}___"
|
|
221
|
+
protected_blocks[placeholder] = match.group(0)
|
|
222
|
+
counter += 1
|
|
223
|
+
return placeholder
|
|
224
|
+
|
|
225
|
+
# Match fenced code blocks (both ``` and ~~~)
|
|
226
|
+
# Indented code blocks are NOT protected to support MkDocs admonitions
|
|
227
|
+
markdown = re.sub(
|
|
228
|
+
r'^```[\s\S]*?^```|^~~~[\s\S]*?^~~~',
|
|
229
|
+
make_placeholder,
|
|
230
|
+
markdown,
|
|
231
|
+
flags=re.MULTILINE
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Extract HTML comments
|
|
235
|
+
markdown = re.sub(
|
|
236
|
+
r'<!--[\s\S]*?-->',
|
|
237
|
+
make_placeholder,
|
|
238
|
+
markdown
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return markdown, protected_blocks
|
|
242
|
+
|
|
243
|
+
def _restore_protected_blocks(self, markdown: str, protected_blocks: dict) -> str:
|
|
244
|
+
"""Restore protected blocks from placeholders."""
|
|
245
|
+
for placeholder, original in protected_blocks.items():
|
|
246
|
+
markdown = markdown.replace(placeholder, original)
|
|
247
|
+
return markdown
|
|
248
|
+
|
|
249
|
+
def _resolve_filename(self, filename: str) -> Optional[str]:
|
|
250
|
+
"""Resolve a filename to its full path within the docs."""
|
|
251
|
+
# Check if file exists in our mapping
|
|
252
|
+
if filename in self.ambiguous_files:
|
|
253
|
+
# Return the first occurrence for ambiguous files
|
|
254
|
+
return self.ambiguous_files[filename][0]
|
|
255
|
+
|
|
256
|
+
return self.file_map.get(filename)
|
|
257
|
+
|
|
258
|
+
def _get_relative_path(self, from_path: str, to_path: str) -> str:
|
|
259
|
+
"""Calculate relative path from one file to another."""
|
|
260
|
+
from_dir = os.path.dirname(from_path)
|
|
261
|
+
rel = os.path.relpath(to_path, from_dir) if from_dir else to_path
|
|
262
|
+
return rel.replace("\\", "/")
|
|
263
|
+
|
|
264
|
+
def on_post_build(self, *, config: MkDocsConfig) -> None:
|
|
265
|
+
"""Display statistics after the build completes."""
|
|
266
|
+
if not self.config["show_stats"]:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
logger.info("=" * 60)
|
|
270
|
+
logger.info("EasyLinks Plugin Statistics")
|
|
271
|
+
logger.info("=" * 60)
|
|
272
|
+
|
|
273
|
+
# File statistics
|
|
274
|
+
logger.info(f"Files scanned: {self.stats['total_files_scanned']}")
|
|
275
|
+
logger.info(f"Files indexed: {self.stats['files_indexed']}")
|
|
276
|
+
logger.info(f"Files ignored: {self.stats['files_ignored']}")
|
|
277
|
+
|
|
278
|
+
# Link statistics
|
|
279
|
+
logger.info(f"\nLinks processed: {self.stats['links_processed']}")
|
|
280
|
+
logger.info(f"Links resolved: {self.stats['links_resolved']}")
|
|
281
|
+
logger.info(f"Links unresolved: {self.stats['links_unresolved']}")
|
|
282
|
+
|
|
283
|
+
# Image statistics
|
|
284
|
+
logger.info(f"\nImages processed: {self.stats['images_processed']}")
|
|
285
|
+
logger.info(f"Images resolved: {self.stats['images_resolved']}")
|
|
286
|
+
logger.info(f"Images unresolved: {self.stats['images_unresolved']}")
|
|
287
|
+
|
|
288
|
+
# Most linked files
|
|
289
|
+
if self.link_counts:
|
|
290
|
+
logger.info(f"\nMost frequently linked files (top 10):")
|
|
291
|
+
sorted_links = sorted(self.link_counts.items(), key=lambda x: x[1], reverse=True)
|
|
292
|
+
for path, count in sorted_links[:10]:
|
|
293
|
+
logger.info(f" {count:3d}x {path}")
|
|
294
|
+
|
|
295
|
+
# Orphaned files (files that are indexed but never linked)
|
|
296
|
+
orphaned = set(self.file_map.values()) - set(self.link_counts.keys())
|
|
297
|
+
if orphaned:
|
|
298
|
+
logger.info(f"\nOrphaned files (indexed but never linked): {len(orphaned)}")
|
|
299
|
+
# Show first 10
|
|
300
|
+
for path in sorted(orphaned)[:10]:
|
|
301
|
+
logger.info(f" - {path}")
|
|
302
|
+
if len(orphaned) > 10:
|
|
303
|
+
logger.info(f" ... and {len(orphaned) - 10} more")
|
|
304
|
+
|
|
305
|
+
logger.info("=" * 60)
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mkdocs-easylinks-plugin
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: An MkDocs plugin that allows linking to files by filename only
|
|
5
|
+
Author: Daniel Ferguson
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dsferg/mkdocs-easylinks-plugin
|
|
8
|
+
Project-URL: Documentation, https://github.com/dsferg/mkdocs-easylinks-plugin
|
|
9
|
+
Project-URL: Repository, https://github.com/dsferg/mkdocs-easylinks-plugin
|
|
10
|
+
Keywords: mkdocs,plugin,links,cross-references
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: mkdocs>=1.4.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# MkDocs EasyLinks Plugin
|
|
31
|
+
|
|
32
|
+
An MkDocs plugin that allows you to create cross-references and embed images by specifying only the filename, without needing to know the full path.
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- **Simple linking**: Reference any file in your docs with just its filename
|
|
37
|
+
- **Image support**: Embed images using simple filenames like ``
|
|
38
|
+
- **Automatic resolution**: The plugin finds the file and generates the correct relative path
|
|
39
|
+
- **Glob patterns**: Use wildcards to ignore multiple files (e.g., `draft-*.md`, `*.tmp`)
|
|
40
|
+
- **Directory exclusion**: Exclude entire directories from being indexed
|
|
41
|
+
- **Link statistics**: See which files are most linked and find orphaned content
|
|
42
|
+
- **Ambiguity warnings**: Get notified if multiple files share the same name
|
|
43
|
+
- **Material for MkDocs compatible**: Works seamlessly with Material theme
|
|
44
|
+
- **Smart protection**: Code fences and HTML comments are preserved unchanged
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install mkdocs-easylinks-plugin
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or install directly from source:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install -e .
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
Add the plugin to your `mkdocs.yml`:
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
plugins:
|
|
64
|
+
- search
|
|
65
|
+
- easylinks
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Configuration Options
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
plugins:
|
|
72
|
+
- easylinks:
|
|
73
|
+
warn_on_missing: true # Warn when a file can't be found (default: true)
|
|
74
|
+
warn_on_ambiguous: true # Warn when multiple files have the same name (default: true)
|
|
75
|
+
ignore_files: [] # List of filenames/patterns to ignore (default: [])
|
|
76
|
+
exclude_dirs: [] # List of directories to exclude (default: [])
|
|
77
|
+
show_stats: false # Show link statistics after build (default: false)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
#### Available Options
|
|
81
|
+
|
|
82
|
+
- **`warn_on_missing`** (bool, default: `true`): Show warnings when a linked file cannot be found
|
|
83
|
+
- **`warn_on_ambiguous`** (bool, default: `true`): Show warnings when multiple files share the same name
|
|
84
|
+
- **`ignore_files`** (list, default: `[]`): List of filenames/patterns to exclude from link resolution (supports glob patterns)
|
|
85
|
+
- **`exclude_dirs`** (list, default: `[]`): List of directories to exclude completely
|
|
86
|
+
- **`show_stats`** (bool, default: `false`): Display link statistics after the build completes
|
|
87
|
+
|
|
88
|
+
#### Ignoring Specific Files
|
|
89
|
+
|
|
90
|
+
Use `ignore_files` to exclude certain files. Supports both exact matches and glob patterns:
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
plugins:
|
|
94
|
+
- easylinks:
|
|
95
|
+
ignore_files:
|
|
96
|
+
- draft.md # Exact match
|
|
97
|
+
- template.md # Exact match
|
|
98
|
+
- "draft-*.md" # Glob pattern - all files starting with "draft-"
|
|
99
|
+
- "*.tmp" # Glob pattern - all .tmp files
|
|
100
|
+
- "test_*" # Glob pattern - all files starting with "test_"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
#### Excluding Directories
|
|
104
|
+
|
|
105
|
+
Use `exclude_dirs` to exclude entire directories:
|
|
106
|
+
|
|
107
|
+
```yaml
|
|
108
|
+
plugins:
|
|
109
|
+
- easylinks:
|
|
110
|
+
exclude_dirs:
|
|
111
|
+
- drafts/
|
|
112
|
+
- templates/
|
|
113
|
+
- .archive/
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
All files in these directories (and subdirectories) will be excluded from indexing.
|
|
117
|
+
|
|
118
|
+
> **Note:** Directory paths are matched as prefixes against each file's path within the docs directory. This means `exclude_dirs: ["api"]` excludes `api/page.md` but **not** `docs/api/page.md`. To exclude a nested directory, specify the full path from the docs root: `exclude_dirs: ["docs/api"]`.
|
|
119
|
+
|
|
120
|
+
#### Link Statistics
|
|
121
|
+
|
|
122
|
+
Enable `show_stats` to see detailed statistics after your build:
|
|
123
|
+
|
|
124
|
+
```yaml
|
|
125
|
+
plugins:
|
|
126
|
+
- easylinks:
|
|
127
|
+
show_stats: true
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This will display:
|
|
131
|
+
- Files scanned and indexed
|
|
132
|
+
- Links processed, resolved, and unresolved
|
|
133
|
+
- Images processed, resolved, and unresolved
|
|
134
|
+
- Most frequently linked files
|
|
135
|
+
- Orphaned files (indexed but never linked)
|
|
136
|
+
|
|
137
|
+
## Examples
|
|
138
|
+
|
|
139
|
+
### Documentation Links
|
|
140
|
+
|
|
141
|
+
Instead of writing:
|
|
142
|
+
|
|
143
|
+
```markdown
|
|
144
|
+
[See the guide](../../advanced/guides/configuration.md)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
You can now write:
|
|
148
|
+
|
|
149
|
+
```markdown
|
|
150
|
+
[See the guide](configuration.md)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The plugin will automatically resolve `configuration.md` to its full path and generate the correct relative link.
|
|
154
|
+
|
|
155
|
+
### Images
|
|
156
|
+
|
|
157
|
+
Works with images too! Instead of:
|
|
158
|
+
|
|
159
|
+
```markdown
|
|
160
|
+

|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Just write:
|
|
164
|
+
|
|
165
|
+
```markdown
|
|
166
|
+

|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### With Anchors
|
|
170
|
+
|
|
171
|
+
Anchors work for document links:
|
|
172
|
+
|
|
173
|
+
```markdown
|
|
174
|
+
[See section](somefile.md#advanced-features)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### What Links Are Processed
|
|
178
|
+
|
|
179
|
+
**Processed** (converted to full paths):
|
|
180
|
+
- `[text](filename.md)` - Simple document filenames
|
|
181
|
+
- `[text](file.md#anchor)` - Document filenames with anchors
|
|
182
|
+
- `` - Simple image filenames (png, jpg, svg, gif, etc.)
|
|
183
|
+
|
|
184
|
+
**Not processed** (left as-is):
|
|
185
|
+
- `[text](https://example.com)` - External URLs
|
|
186
|
+
- `` - External images
|
|
187
|
+
- `[text](/absolute/path.md)` - Absolute paths
|
|
188
|
+
- `[text](../relative/path.md)` - Explicit relative paths with directories
|
|
189
|
+
- `[text](#anchor)` - Fragment-only links
|
|
190
|
+
- Links/images inside code fences (` ``` ` or `~~~`)
|
|
191
|
+
- Links/images inside HTML comments (`<!-- -->`)
|
|
192
|
+
|
|
193
|
+
### Protected Content
|
|
194
|
+
|
|
195
|
+
The plugin intelligently ignores links in:
|
|
196
|
+
|
|
197
|
+
**Code fences:**
|
|
198
|
+
````markdown
|
|
199
|
+
```python
|
|
200
|
+
# This [link](example.md) won't be processed
|
|
201
|
+
```
|
|
202
|
+
````
|
|
203
|
+
|
|
204
|
+
**HTML comments:**
|
|
205
|
+
```markdown
|
|
206
|
+
<!-- This [link](example.md) won't be processed -->
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
This ensures that example code and commented-out content remain unchanged.
|
|
210
|
+
|
|
211
|
+
**Important: Indented Content**
|
|
212
|
+
|
|
213
|
+
Only explicit code fences (``` or ~~~) are protected. Indented content, such as in MkDocs admonitions, **is processed normally**:
|
|
214
|
+
|
|
215
|
+
```markdown
|
|
216
|
+
!!! note
|
|
217
|
+
This [link](guide.md) WILL be processed.
|
|
218
|
+
The plugin works inside admonitions!
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
This design choice ensures the plugin works seamlessly with MkDocs features like admonitions, which rely heavily on indentation.
|
|
222
|
+
|
|
223
|
+
## How It Works
|
|
224
|
+
|
|
225
|
+
1. During the build, the plugin scans all files (documentation, images, assets, etc.)
|
|
226
|
+
2. It creates a mapping of filenames to their full paths
|
|
227
|
+
3. When processing each page, it finds markdown links and images with simple filenames
|
|
228
|
+
4. It replaces them with the correct relative path from the current page to the target
|
|
229
|
+
|
|
230
|
+
**Files that are excluded from mapping:**
|
|
231
|
+
- Files starting with `.` (dotfiles) - always ignored
|
|
232
|
+
- Files listed in `ignore_files` configuration - useful for drafts and templates
|
|
233
|
+
|
|
234
|
+
## Using with mkdocs-macros-plugin (Snippets)
|
|
235
|
+
|
|
236
|
+
If you use [mkdocs-macros-plugin](https://mkdocs-macros-plugin.readthedocs.io/) to include snippet files via `{% include '...' %}`, easylinks works correctly with no extra configuration — as long as the plugin order in `mkdocs.yml` is correct:
|
|
237
|
+
|
|
238
|
+
```yaml
|
|
239
|
+
plugins:
|
|
240
|
+
- macros:
|
|
241
|
+
include_dir: docs/.snippets
|
|
242
|
+
- easylinks # must come after macros
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Because macros runs first, snippet content is inlined into the page before easylinks processes it. Links in snippets are resolved relative to the **including page**, not the snippet file itself — which is exactly the right behaviour when a snippet may be included from pages at different directory levels.
|
|
246
|
+
|
|
247
|
+
**Best practices:**
|
|
248
|
+
|
|
249
|
+
- Always list `easylinks` after `macros` in your `plugins` configuration.
|
|
250
|
+
- Use simple filename links (e.g. `[text](target.md)`) in your snippets rather than relative paths, since relative paths would break when the same snippet is included from different locations.
|
|
251
|
+
- If your snippets directory could contain files whose names clash with published docs files, add it to `exclude_dirs` to prevent false ambiguity warnings:
|
|
252
|
+
|
|
253
|
+
```yaml
|
|
254
|
+
plugins:
|
|
255
|
+
- easylinks:
|
|
256
|
+
exclude_dirs: ['.snippets']
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Handling Ambiguous Filenames
|
|
260
|
+
|
|
261
|
+
If you have multiple files with the same name (e.g., `index.md` in different folders), the plugin will:
|
|
262
|
+
|
|
263
|
+
1. Warn you about the ambiguity
|
|
264
|
+
2. Use the first occurrence found
|
|
265
|
+
3. Recommend using full paths for those specific files
|
|
266
|
+
|
|
267
|
+
## Development
|
|
268
|
+
|
|
269
|
+
### Setup
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
# Clone the repository
|
|
273
|
+
git clone https://github.com/dsferg/mkdocs-easylinks-plugin.git
|
|
274
|
+
cd mkdocs-easylinks-plugin
|
|
275
|
+
|
|
276
|
+
# Install in development mode
|
|
277
|
+
pip install -e ".[dev]"
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Running Tests
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
pytest
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## License
|
|
287
|
+
|
|
288
|
+
MIT License - See LICENSE file for details
|
|
289
|
+
|
|
290
|
+
## Contributing
|
|
291
|
+
|
|
292
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
mkdocs_easylinks/__init__.py,sha256=M2lR8vBkkdgXh50QhguwlTZvmWvJyGgR0VzXBB68aB0,97
|
|
2
|
+
mkdocs_easylinks/plugin.py,sha256=2ibhM3614UUGMtMZhPXx3lT8jsomoF98I6O1pq0zjbg,12412
|
|
3
|
+
mkdocs_easylinks_plugin-0.1.1.dist-info/licenses/LICENSE,sha256=dPBp6YMZ7j-4u99ZFKB3n3aVSAZ7kbZxdvJoKW_vV2I,1072
|
|
4
|
+
mkdocs_easylinks_plugin-0.1.1.dist-info/METADATA,sha256=1ADTukmaxg3hCUBUQgBh7M5Ln4gyRPWIFSiCXQuxnug,9182
|
|
5
|
+
mkdocs_easylinks_plugin-0.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
mkdocs_easylinks_plugin-0.1.1.dist-info/entry_points.txt,sha256=TapjTFtRdQu0AygregEKyirmeWn0LZgvTOBSFDJeLSE,69
|
|
7
|
+
mkdocs_easylinks_plugin-0.1.1.dist-info/top_level.txt,sha256=m1XUzruB4-Zu2zavZksLm1o_YoZJAg4fSapZDnvH9WA,17
|
|
8
|
+
mkdocs_easylinks_plugin-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Ferguson
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mkdocs_easylinks
|