mkdocs-permalinks-plugin 0.1.0__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.
File without changes
@@ -0,0 +1,253 @@
1
+ import re
2
+ import os
3
+ import logging
4
+ import urllib.parse
5
+
6
+ from mkdocs.plugins import BasePlugin
7
+ import mkdocs.utils
8
+ from mkdocs.config import config_options
9
+
10
+ log = logging.getLogger(f"mkdocs.plugins.{__name__}")
11
+ log.addFilter(mkdocs.utils.warning_filter)
12
+
13
+ # For Regex, match groups are:
14
+ # 0: Whole markdown link e.g. [Alt-text](url)
15
+ # 1: Alt text
16
+ # 2: Full URL e.g. url + hash anchor
17
+ # 3: Filename e.g. filename.md
18
+ # 4: File extension e.g. .md, .png, etc.
19
+ # 5. hash anchor e.g. #my-sub-heading-link
20
+ AUTOLINK_RE = r'\[([^\]]+)\]\((([^)/]+\.(md|png|jpg))(#.*)*)\)'
21
+
22
+ # For Regex, match groups are:
23
+ # 0: Whole roamlike link e.g. [[filename#title|alias|widthxheight]]
24
+ # 1: Link content e.g. filename#title|alias|widthxheight
25
+ ROAMLINK_RE = r"""\[\[(.*?)\]\](?!\])"""
26
+
27
+ # Regex to match both inline and fenced codeblocks
28
+ CODEBLOCK_RE = r'(```.*?```|`.*?`)'
29
+
30
+ class AutoLinkReplacer:
31
+ def __init__(self, base_docs_url, page_url):
32
+ self.base_docs_url = base_docs_url
33
+ self.page_url = page_url
34
+
35
+ def __call__(self, match):
36
+ # Name of the markdown file
37
+ filename = urllib.parse.unquote(match.group(3).strip())
38
+
39
+ # Absolute URL of the linker
40
+ abs_linker_url = os.path.dirname(
41
+ os.path.join(self.base_docs_url, self.page_url))
42
+
43
+ # Find directory URL to target link
44
+ rel_link_url = ''
45
+ # Walk through all files in docs directory to find a matching file
46
+ for root, dirs, files in os.walk(self.base_docs_url, followlinks=True):
47
+ for name in files:
48
+ # If we have a match, create the relative path from linker to the link
49
+ if name == filename:
50
+ # Absolute path to the file we want to link to
51
+ abs_link_url = os.path.dirname(os.path.join(root, name))
52
+ # Constructing relative path from the linker to the link
53
+ rel_link_url = os.path.join(
54
+ os.path.relpath(abs_link_url, abs_linker_url),
55
+ filename)
56
+ if rel_link_url == '':
57
+ log.warning(f"AutoLinksPlugin unable to find {filename} in directory {self.base_docs_url}")
58
+ return match.group(0)
59
+
60
+ # Construct the return link by replacing the filename with the relative path to the file
61
+ if (match.group(5) == None):
62
+ link = match.group(0).replace(match.group(2), rel_link_url)
63
+ else:
64
+ link = match.group(0).replace(match.group(2),
65
+ rel_link_url + match.group(5))
66
+ return link
67
+
68
+
69
+ class RoamLinkReplacer:
70
+ def __init__(self, base_docs_url, page_url):
71
+ self.base_docs_url = base_docs_url
72
+ self.page_url = page_url
73
+
74
+ def simplify(self, filename):
75
+ """ ignore - _ and space different, replace .md to '' so it will match .md file,
76
+ if you want to link to png, make sure you filename contain suffix .png, same for other files
77
+ but if you want to link to markdown, you don't need suffix .md """
78
+ return re.sub(r"[\-_ ]", "", filename.lower()).replace(".md", "")
79
+
80
+ def gfm_anchor(self, title):
81
+ """Convert to gfw title / anchor
82
+ see: https://gist.github.com/asabaylus/3071099#gistcomment-1593627"""
83
+ if title:
84
+ title = title.strip().lower()
85
+ title = re.sub(r'[^\w\u4e00-\u9fff\- ]', "", title)
86
+ title = re.sub(r' +', "-", title)
87
+ return title
88
+ else:
89
+ return ""
90
+
91
+ def unresolved_text(self, filename, title, alias):
92
+ """Return the plain text to keep when no target file can be found."""
93
+ if alias:
94
+ return alias
95
+ return filename + title
96
+
97
+ def parse_link_content(self, content):
98
+ filename = ""
99
+ title = ""
100
+ alias = ""
101
+ width = ""
102
+ height = ""
103
+
104
+ parts = content.split("|")
105
+ target = parts[0].strip()
106
+ modifiers = parts[1:]
107
+
108
+ if modifiers:
109
+ size = modifiers[-1].strip()
110
+ size_match = re.fullmatch(r"(\d+)(?:x(\d+))?", size)
111
+ if size_match:
112
+ width = size_match.group(1) or ""
113
+ height = size_match.group(2) or ""
114
+ modifiers = modifiers[:-1]
115
+ if modifiers:
116
+ alias = "|".join(modifiers)
117
+
118
+ if target.startswith("#"):
119
+ title = target
120
+ elif "#" in target:
121
+ filename, heading = target.split("#", 1)
122
+ title = "#" + heading
123
+ filename = filename.strip()
124
+ else:
125
+ filename = target
126
+
127
+ return filename, title, alias, width, height
128
+
129
+ def __call__(self, match):
130
+ # Name of the markdown file
131
+ filename, title, alias, width, height = self.parse_link_content(match.group(1))
132
+ format_title = self.gfm_anchor(title)
133
+
134
+ # Absolute URL of the linker
135
+ abs_linker_url = os.path.dirname(
136
+ os.path.join(self.base_docs_url, self.page_url))
137
+
138
+ # Find directory URL to target link
139
+ rel_link_url = ''
140
+ # Walk through all files in docs directory to find a matching file
141
+ if filename:
142
+ if '/' in filename:
143
+ if 'http' in filename: # http or https
144
+ rel_link_url = filename
145
+ else:
146
+ rel_file = filename
147
+ if not '.' in filename: # don't have extension type
148
+ rel_file = filename + ".md"
149
+
150
+ abs_link_url = os.path.dirname(os.path.join(
151
+ self.base_docs_url, rel_file))
152
+ # Constructing relative path from the linker to the link
153
+ rel_link_url = os.path.join(
154
+ os.path.relpath(abs_link_url, abs_linker_url), os.path.basename(rel_file))
155
+ if title:
156
+ rel_link_url = rel_link_url + '#' + format_title
157
+ else:
158
+ for root, dirs, files in os.walk(self.base_docs_url, followlinks=True):
159
+ for name in files:
160
+ # If we have a match, create the relative path from linker to the link
161
+ if self.simplify(name) == self.simplify(filename):
162
+ # Absolute path to the file we want to link to
163
+ abs_link_url = os.path.dirname(os.path.join(
164
+ root, name))
165
+ # Constructing relative path from the linker to the link
166
+ rel_link_url = os.path.join(
167
+ os.path.relpath(abs_link_url, abs_linker_url), name)
168
+ if title:
169
+ rel_link_url = rel_link_url + '#' + format_title
170
+ if rel_link_url == '':
171
+ log.warning(f"PermaLinksPlugin unable to find {filename} in directory {self.base_docs_url}")
172
+ return self.unresolved_text(filename, title, alias)
173
+ else:
174
+ rel_link_url = '#' + format_title
175
+
176
+ # Construct the return link
177
+ # Windows escapes "\" unintentionally, and it creates incorrect links, so need to replace with "/"
178
+ rel_link_url = rel_link_url.replace("\\", "/")
179
+
180
+ if filename:
181
+ if alias:
182
+ link = f'[{alias}](<{rel_link_url}>)'
183
+ else:
184
+ link = f'[{filename+title}](<{rel_link_url}>)'
185
+ else:
186
+ if alias:
187
+ link = f'[{alias}](<{rel_link_url}>)'
188
+ else:
189
+ link = f'[{title}](<{rel_link_url}>)'
190
+
191
+ if width and not height:
192
+ link = f'{link}{{ width="{width}" }}'
193
+ elif not width and height:
194
+ link = f'{link}{{ height="{height}" }}'
195
+ elif width and height:
196
+ link = f'{link}{{ width="{width}"; height="{height}" }}'
197
+
198
+ return link
199
+
200
+ def redact_codeblocks(markdown):
201
+ """ Redact codeblocks to avoid processing links inside codeblocks """
202
+ codeblock_re = re.compile(CODEBLOCK_RE, re.DOTALL)
203
+ redacted_blocks = {}
204
+
205
+ def replacer(match):
206
+ key = f"\x01__CODEBLOCK_{len(redacted_blocks)}__\x02"
207
+ redacted_blocks[key] = match.group(0)
208
+ return key
209
+
210
+ redacted_markdown = codeblock_re.sub(replacer, markdown)
211
+ return redacted_markdown, redacted_blocks
212
+
213
+ def restore_codeblocks(markdown, redacted_blocks):
214
+ """ Restore redacted codeblocks """
215
+ for key, block in redacted_blocks.items():
216
+ markdown = markdown.replace(key, block)
217
+
218
+ return markdown
219
+
220
+ class PermaLinksPlugin(BasePlugin):
221
+ config_scheme = (
222
+ ('ignore_codeblocks', config_options.Type(bool, default=True)),
223
+ )
224
+
225
+ def on_page_markdown(self,
226
+ markdown,
227
+ page,
228
+ config,
229
+ site_navigation=None,
230
+ **kwargs):
231
+
232
+ # Getting the root location of markdown source files
233
+ base_docs_url = config["docs_dir"]
234
+
235
+ # Getting the page url that we are linking from
236
+ page_url = page.file.src_path
237
+
238
+ # Redact codeblocks
239
+ redacted_blocks = {}
240
+ if self.config['ignore_codeblocks']:
241
+ markdown, redacted_blocks = redact_codeblocks(markdown)
242
+
243
+ # Look for matches and replace
244
+ markdown = re.sub(AUTOLINK_RE,
245
+ AutoLinkReplacer(base_docs_url, page_url), markdown)
246
+ markdown = re.sub(ROAMLINK_RE,
247
+ RoamLinkReplacer(base_docs_url, page_url), markdown)
248
+
249
+ # Restore codeblocks
250
+ if self.config['ignore_codeblocks']:
251
+ markdown = restore_codeblocks(markdown, redacted_blocks)
252
+
253
+ return markdown
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Matthew Marchetti
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.1
2
+ Name: mkdocs-permalinks-plugin
3
+ Version: 0.1.0
4
+ Summary: An MkDocs plugin
5
+ Home-page: https://github.com/Mattlock0/mkdocs-permalinks-plugin
6
+ Author:
7
+ Author-email: visionofhonor@gmail.com
8
+ License: MIT
9
+ Keywords: mkdocs
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Information Technology
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3.6
16
+ Classifier: Programming Language :: Python :: 3.7
17
+ Requires-Python: >=3.6
18
+ License-File: LICENSE
19
+ Requires-Dist: mkdocs >=1.0.4
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest ; extra == 'dev'
22
+
23
+ An MkDocs plugin that automagically generates relative links and convert links for foam and obsidian between markdown pages
@@ -0,0 +1,10 @@
1
+ mkdocs_permalinks_plugin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ mkdocs_permalinks_plugin/plugin.py,sha256=ePLOmu40Ho-DbG6onAJ-3QN3cMHB-K6crzez_aIUW-k,9939
3
+ tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ tests/test_plugin.py,sha256=pKeZ9kQHAMv7NqT9XzpfW2FvMaiXo4tCrU-oWGV8YUc,7711
5
+ mkdocs_permalinks_plugin-0.1.0.dist-info/LICENSE,sha256=TgPExXd9i1aGN11nz1N_jZVzUsZBdsIfvDCGYVU1LFs,1080
6
+ mkdocs_permalinks_plugin-0.1.0.dist-info/METADATA,sha256=YYcZ6xDR-8Wk31a6VJlv3RvtUUbVHQwwT0k8hVy10vY,855
7
+ mkdocs_permalinks_plugin-0.1.0.dist-info/WHEEL,sha256=yQN5g4mg4AybRjkgi-9yy4iQEFibGQmlz78Pik5Or-A,92
8
+ mkdocs_permalinks_plugin-0.1.0.dist-info/entry_points.txt,sha256=lKDJfVVxspi_yhtPsA0Mh80wjQ1jFXn15BfP06ZPZtY,79
9
+ mkdocs_permalinks_plugin-0.1.0.dist-info/top_level.txt,sha256=TeA-b4N3MvhuRK9U6tY8hQiYRDlfYpsPE834N0WwEMY,31
10
+ mkdocs_permalinks_plugin-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.41.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [mkdocs.plugins]
2
+ permalinks = mkdocs_permalinks_plugin.plugin:PermaLinksPlugin
@@ -0,0 +1,2 @@
1
+ mkdocs_permalinks_plugin
2
+ tests
tests/__init__.py ADDED
File without changes
tests/test_plugin.py ADDED
@@ -0,0 +1,194 @@
1
+ import os
2
+ import tempfile
3
+ import pytest
4
+
5
+ from mkdocs.structure.files import File
6
+ from mkdocs.structure.pages import Page
7
+ from mkdocs_permalinks_plugin.plugin import PermaLinksPlugin
8
+
9
+
10
+ @pytest.fixture
11
+ def temp_directory():
12
+ with tempfile.TemporaryDirectory() as temp_dir:
13
+ yield temp_dir
14
+
15
+
16
+ @pytest.fixture
17
+ def config(temp_directory):
18
+ return {"docs_dir": temp_directory}
19
+
20
+
21
+ @pytest.fixture
22
+ def site_navigation():
23
+ return []
24
+
25
+
26
+ @pytest.fixture
27
+ def page(temp_directory):
28
+ os.mkdir(os.path.join(temp_directory, "test"))
29
+ file_path = os.path.join(temp_directory, "test", "test.md")
30
+ with open(file_path, "w", encoding="utf8") as f:
31
+ f.write("# Heading identifiers in HTML")
32
+ with open(os.path.join(temp_directory, "demo (t).md"), "w", encoding="utf8") as f:
33
+ f.write("# Demo Page")
34
+ with open(os.path.join(temp_directory, "image.png"), "w", encoding="utf8") as f:
35
+ f.write("# Image Page")
36
+ with open(os.path.join(temp_directory, "image (1).png"), "w", encoding="utf8") as f:
37
+ f.write("# Image Page")
38
+ with open(os.path.join(temp_directory, "41m+ZoNoWqL._AC_UF894,1000_QL80_.jpg"), "w", encoding="utf8") as f:
39
+ f.write("# Image Page")
40
+ os.mkdir(os.path.join(temp_directory, "software"))
41
+ with open(
42
+ os.path.join(temp_directory, "software", "git_flow.md"), "w", encoding="utf8"
43
+ ) as f:
44
+ f.write("# Git Flow")
45
+
46
+ return Page(
47
+ title="Test Page",
48
+ file=File(file_path, temp_directory, temp_directory, False),
49
+ config={},
50
+ )
51
+
52
+ @pytest.fixture
53
+ def converter(temp_directory, config, site_navigation, page):
54
+ def c(markdown, ignore_codeblocks=True):
55
+ plugin = PermaLinksPlugin()
56
+ plugin.load_config({'ignore_codeblocks': ignore_codeblocks})
57
+ return plugin.on_page_markdown(markdown, page, config, site_navigation)
58
+
59
+ return c
60
+
61
+ ###############################################################################{}
62
+ ## Text Links
63
+ ###############################################################################{}
64
+
65
+ def test_converts_basic_link(converter):
66
+ assert converter("[[Git Flow]]") == "[Git Flow](<../software/git_flow.md>)"
67
+
68
+ def test_converts_link_with_slash(converter):
69
+ assert converter("[[software/Git Flow]]") == "[software/Git Flow](<../software/Git Flow.md>)"
70
+
71
+ def test_converts_link_with_anchor_only(converter):
72
+ assert converter("[[#Heading identifiers]]") == "[#Heading identifiers](<#heading-identifiers>)"
73
+
74
+ def test_converts_link_with_anchor(converter):
75
+ assert converter("[[Git Flow#Heading]]") == "[Git Flow#Heading](<../software/git_flow.md#heading>)"
76
+
77
+ def test_converts_link_with_parenthesis(converter):
78
+ assert converter("[[demo (t)]]") == "[demo (t)](<../demo (t).md>)"
79
+
80
+ def test_converts_link_with_parenthesis_and_space(converter):
81
+ assert converter("[demo (t)](<../demo%20(t).md>)") == "[demo (t)](<../demo%20(t).md>)"
82
+
83
+ def test_converts_link_with_spaces_in_text(converter):
84
+ assert converter('[[Git Flow|Title With Spaces]]') == '[Title With Spaces](<../software/git_flow.md>)'
85
+
86
+ def test_converts_link_with_punctuation_in_text(converter):
87
+ assert converter('[[Git Flow|Title, with. Punctuation!]]') == '[Title, with. Punctuation!](<../software/git_flow.md>)'
88
+
89
+ def test_converts_link_with_square_brackets_in_text(converter):
90
+ assert converter('[[Git Flow|Title [with] square [brackets]]]') == '[Title [with] square [brackets]](<../software/git_flow.md>)'
91
+
92
+ def test_converts_link_with_single_quotes_in_text(converter):
93
+ assert converter("[[Git Flow|Title 'with' single 'quotes']]") == "[Title 'with' single 'quotes'](<../software/git_flow.md>)"
94
+
95
+ def test_converts_link_with_double_quotes_in_text(converter):
96
+ assert converter('[[Git Flow|Title "with" double "quotes"]]') == '[Title "with" double "quotes"](<../software/git_flow.md>)'
97
+
98
+ def test_converts_link_with_fragment_identifier(converter):
99
+ assert converter('[[Git Flow#section|Title]]') == '[Title](<../software/git_flow.md#section>)'
100
+
101
+ def test_unresolved_link_becomes_plain_text(converter):
102
+ assert converter("[[Missing Page]]") == "Missing Page"
103
+
104
+ def test_unresolved_link_with_alias_becomes_alias_text(converter):
105
+ assert converter("[[Missing Page|Readable Title]]") == "Readable Title"
106
+
107
+ ###############################################################################{}
108
+ ## Code Blocks (ignore_codeblocks=True, default)
109
+ ###############################################################################{}
110
+
111
+ def test_dont_converts_link_inside_inline_code_block(converter):
112
+ input_md = 'Here is a link `[[Git Flow]]` inside code'
113
+ assert converter(input_md) == input_md
114
+
115
+ def test_dont_converts_link_inside_fenced_code_block(converter):
116
+ input_md = '''Here is a link
117
+ ```
118
+ [[Git Flow]]
119
+ ```
120
+ inside code'''
121
+
122
+ assert converter(input_md) == input_md
123
+
124
+ def test_dont_converts_link_inside_fenced_code_block_with_inline_code(converter):
125
+ input_md = '''Here is a link
126
+ ```
127
+ `[[Git Flow]]`
128
+ ```
129
+ inside code'''
130
+
131
+ assert converter(input_md) == input_md
132
+
133
+ ###############################################################################{}
134
+ ## Code Blocks (ignore_codeblocks=False)
135
+ ###############################################################################{}
136
+
137
+ def test_converts_link_inside_inline_code_block_when_ignore_false(converter):
138
+ input_md = 'Here is a link `[[Git Flow]]` inside code'
139
+ expected = 'Here is a link `[Git Flow](<../software/git_flow.md>)` inside code'
140
+ assert converter(input_md, ignore_codeblocks=False) == expected
141
+
142
+ def test_converts_link_inside_fenced_code_block_when_ignore_false(converter):
143
+ input_md = '''Here is a link
144
+ ```
145
+ [[Git Flow]]
146
+ ```
147
+ inside code'''
148
+
149
+ expected = '''Here is a link
150
+ ```
151
+ [Git Flow](<../software/git_flow.md>)
152
+ ```
153
+ inside code'''
154
+
155
+ assert converter(input_md, ignore_codeblocks=False) == expected
156
+
157
+ def test_converts_link_inside_fenced_code_block_with_inline_code_when_ignore_false(converter):
158
+ input_md = '''Here is a link
159
+ ```
160
+ `[[Git Flow]]`
161
+ ```
162
+ inside code'''
163
+
164
+ expected = '''Here is a link
165
+ ```
166
+ `[Git Flow](<../software/git_flow.md>)`
167
+ ```
168
+ inside code'''
169
+
170
+ assert converter(input_md, ignore_codeblocks=False) == expected
171
+
172
+ ###############################################################################{}
173
+ ## Images
174
+ ###############################################################################{}
175
+ def test_converts_basic_image_link(converter):
176
+ assert converter("![[image.png]]") == '![image.png](<../image.png>)'
177
+
178
+ def test_converts_crazy_image_link(converter):
179
+ assert converter("![[41m+ZoNoWqL._AC_UF894,1000_QL80_.jpg|Edimax EW-7811un 802.11n WiFi Adapter]]") == '![Edimax EW-7811un 802.11n WiFi Adapter](<../41m+ZoNoWqL._AC_UF894,1000_QL80_.jpg>)'
180
+
181
+ def test_converts_image_link_with_width(converter):
182
+ assert converter("![[image.png|600]]") == '![image.png](<../image.png>){ width="600" }'
183
+
184
+ def test_converts_image_link_with_width_and_height(converter):
185
+ assert converter("![[image.png|600x800]]") == '![image.png](<../image.png>){ width="600"; height="800" }'
186
+
187
+ def test_converts_image_link_with_title_and_width(converter):
188
+ assert converter("![[image.png|Image|600]]") == '![Image](<../image.png>){ width="600" }'
189
+
190
+ def test_converts_image_link_with_parenthesis_title_and_width(converter):
191
+ assert converter("![[image (1).png|Image|600]]") == '![Image](<../image (1).png>){ width="600" }'
192
+
193
+ def test_converts_image_link_with_parenthesis_title_width_and_height(converter):
194
+ assert converter("![[image (1).png|Image|600x200]]") == '![Image](<../image (1).png>){ width="600"; height="200" }'