Skip to content

Commit 49bfbe8

Browse files
committed
Allow using escaped square brackets within aliases #26
1 parent 563d2cc commit 49bfbe8

4 files changed

Lines changed: 355 additions & 351 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,14 @@ This is a limitation of extending the Markdown syntax with non-standard features
172172

173173
Since markdownlint isn't aware of alias-style links, the links are considered "unused" by the linter. To get around this, disable rule "MD053" in your `.markdownlint.json` file.
174174

175+
### How do I use square brackets in an alias?
176+
177+
To use square brackets in the custom substitution text of an alias, you can escape them with a backslash (`\`). For example:
178+
179+
```md
180+
[[the-alias|The \[bracketed\] Title]]
181+
```
182+
175183
## Local Development
176184

177185
Upgrade pip and install the dependencies:
@@ -201,6 +209,12 @@ pylint $(git ls-files '*.py') && pytest -vv
201209

202210
## Changelog
203211

212+
### 0.10.2
213+
214+
2026-02-09
215+
216+
**Bug Fix**: Fixes a bug where escaped square brackets in an alias would not be included in the generated link.
217+
204218
### 0.10.1
205219

206220
2026-01-08

alias/plugin.py

Lines changed: 78 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
An MkDocs plugin allowing links to your pages using a custom alias.
44
"""
5+
56
from __future__ import annotations
67

78
import logging
@@ -19,42 +20,45 @@
1920
# group 1: escape character
2021
# group 2: alias name
2122
# group 3: alias text
22-
ALIAS_TAG_REGEX = r"([\\])?\[\[([^|\]]+)\|?([^\]]+)?\]\]"
23+
ALIAS_TAG_REGEX = r"([\\])?\[\[([^|\]]+)\|?((?:[^\]\\]|\\.)+)?\]\]"
2324
# RE for finding reference-style alias links
2425
REFERENCE_REGEX = re.compile(
25-
r'^' # Start of the line
26-
r'\[(?P<ref_id>[^\]]+)\]:\s*' # 1. [ref_id]:
27-
r'\[\['
28-
r'(?P<alias_name>[^|\]]+)' # 2. [[page-alias#anchor]]
29-
r'\]\]'
30-
r'[ \t]*$', # Optional trailing spaces and end of line
31-
re.MULTILINE
26+
r"^" # Start of the line
27+
r"\[(?P<ref_id>[^\]]+)\]:\s*" # 1. [ref_id]:
28+
r"\[\["
29+
r"(?P<alias_name>[^|\]]+)" # 2. [[page-alias#anchor]]
30+
r"\]\]"
31+
r"[ \t]*$", # Optional trailing spaces and end of line
32+
re.MULTILINE,
3233
)
3334

3435

3536
class MarkdownAnchor(TypedDict):
3637
"""A single entry in the table of contents. See the following link for more info:
3738
https://python-markdown.github.io/extensions/toc/#syntax"""
39+
3840
level: int
3941
id: str
4042
name: str
41-
children: list['MarkdownAnchor']
43+
children: list["MarkdownAnchor"]
4244

4345

4446
def get_markdown_toc(markdown_source) -> list[MarkdownAnchor]:
4547
"""Parse the markdown source and return the table of contents tokens."""
46-
md = Markdown(extensions=['toc'])
48+
md = Markdown(extensions=["toc"])
4749
md.convert(markdown_source)
48-
return getattr(md, 'toc_tokens', [])
50+
return getattr(md, "toc_tokens", [])
4951

5052

51-
def find_anchor_by_id(anchors: list[MarkdownAnchor], anchor_id: str) -> MarkdownAnchor | None:
53+
def find_anchor_by_id(
54+
anchors: list[MarkdownAnchor], anchor_id: str
55+
) -> MarkdownAnchor | None:
5256
"""Find an anchor by its ID in a list of anchors returned by get_markdown_toc."""
5357
for anchor in anchors:
54-
if anchor['id'] == anchor_id:
58+
if anchor["id"] == anchor_id:
5559
return anchor
56-
if 'children' in anchor:
57-
child = find_anchor_by_id(anchor['children'], anchor_id)
60+
if "children" in anchor:
61+
child = find_anchor_by_id(anchor["children"], anchor_id)
5862
if child is not None:
5963
return child
6064
return None
@@ -63,26 +67,26 @@ def find_anchor_by_id(anchors: list[MarkdownAnchor], anchor_id: str) -> Markdown
6367
def get_page_title(page_src: str, meta_data: dict, include_icon: bool = False):
6468
"""Returns the title of the page. The title in the meta data section
6569
will take precedence over the H1 markdown title if both are provided."""
66-
if 'title' in meta_data and isinstance(meta_data['title'], str):
67-
title = meta_data['title']
68-
if include_icon and 'icon' in meta_data and isinstance(meta_data['icon'], str):
69-
icon = ':' + meta_data['icon'].replace('/', '-') + ':'
70-
title = f'{icon} {title}'
70+
if "title" in meta_data and isinstance(meta_data["title"], str):
71+
title = meta_data["title"]
72+
if include_icon and "icon" in meta_data and isinstance(meta_data["icon"], str):
73+
icon = ":" + meta_data["icon"].replace("/", "-") + ":"
74+
title = f"{icon} {title}"
7175
else:
7276
title = get_markdown_title(page_src)
7377
return title
7478

7579

76-
def get_alias_names(meta_data: dict, meta_key: str = 'alias') -> list[str] | None:
80+
def get_alias_names(meta_data: dict, meta_key: str = "alias") -> list[str] | None:
7781
"""Returns the list of configured alias names."""
7882
if len(meta_data) <= 0 or meta_key not in meta_data:
7983
return None
8084
aliases = meta_data[meta_key]
8185
if isinstance(aliases, list):
8286
# If the alias meta data is a list, ensure that they're strings
8387
return list(filter(lambda value: isinstance(value, str), aliases))
84-
if isinstance(aliases, dict) and 'name' in aliases:
85-
return [aliases['name']]
88+
if isinstance(aliases, dict) and "name" in aliases:
89+
return [aliases["name"]]
8690
if isinstance(aliases, str):
8791
return [aliases]
8892
return None
@@ -93,30 +97,28 @@ def replace_tag(
9397
aliases: dict,
9498
log: logging.Logger,
9599
page_file: File,
96-
use_anchor_titles: bool = False
100+
use_anchor_titles: bool = False,
97101
):
98102
"""Callback used in the sub function within on_page_markdown."""
99103
if match.group(1) is not None:
100104
# if the alias match was escaped, return the unescaped version
101105
return match.group(0)[1:]
102106
# split the tag up in case there's an anchor in the link
103-
tag_bits = ['']
107+
tag_bits = [""]
104108
if match.group(2) is not None:
105-
tag_bits = str(match.group(2)).split('#')
109+
tag_bits = str(match.group(2)).split("#")
106110
alias = aliases.get(tag_bits[0])
107111

108112
# if the alias is not found, log a warning and return the input string
109113
# unless the alias is an anchor tag, then try to find the anchor tag
110114
# and replace it with the anchor's title
111115
if alias is None:
112116
matched = str(match.group(2))
113-
if len(tag_bits) < 2 or not matched.startswith('#'):
117+
if len(tag_bits) < 2 or not matched.startswith("#"):
114118
log.warning(
115-
"Alias '%s' not found in '%s'",
116-
match.group(2),
117-
page_file.src_path
119+
"Alias '%s' not found in '%s'", match.group(2), page_file.src_path
118120
)
119-
return match.group(0) # return the input string
121+
return match.group(0) # return the input string
120122
# using the [[#anchor]] syntax to link within the current page:
121123
anchor = tag_bits[1]
122124
anchors = get_markdown_toc(page_file.content_string)
@@ -132,46 +134,41 @@ def replace_tag(
132134
# if the use_anchor_titles config option is set, replace the text with the
133135
# anchor title, but only if the alias tag doesn't have a custom text
134136
if use_anchor_titles and anchor is not None and match.group(3) is None:
135-
anchor_tag = find_anchor_by_id(alias['anchors'], anchor)
137+
anchor_tag = find_anchor_by_id(alias["anchors"], anchor)
136138
if anchor_tag is not None:
137-
text = anchor_tag['name']
139+
text = anchor_tag["name"]
138140
if text is None:
139141
# if the alias tag has a custom text, use that instead
140-
text = alias['text'] if match.group(3) is None else match.group(3)
142+
text = alias["text"] if match.group(3) is None else match.group(3)
141143
# if the alias tag has no text, use the alias URL
142144
if text is None:
143-
text = alias['url']
145+
text = alias["url"]
144146

145-
url = get_relative_url(alias['url'], page_file.src_uri)
147+
url = get_relative_url(alias["url"], page_file.src_uri)
146148
if anchor is not None:
147149
url = f"{url}#{tag_bits[1]}"
148150

149-
log.info(
150-
"replaced alias '%s' with '%s' to '%s'",
151-
alias['alias'],
152-
text,
153-
url
154-
)
151+
log.info("replaced alias '%s' with '%s' to '%s'", alias["alias"], text, url)
155152
return f"[{text}]({url})"
156153

157154

158155
def replace_reference(match, aliases, log, page_file):
159156
"""Callback used in the sub function within on_page_markdown for
160157
reference-style links."""
161-
ref_id = match.group('ref_id')
162-
alias_name = match.group('alias_name')
158+
ref_id = match.group("ref_id")
159+
alias_name = match.group("alias_name")
163160

164-
tag_bits = alias_name.split('#', 1)
161+
tag_bits = alias_name.split("#", 1)
165162
base_alias = tag_bits[0]
166163
anchor = tag_bits[1] if len(tag_bits) > 1 else None
167164

168165
alias = aliases.get(base_alias)
169166
if alias is None:
170167
log.warning(f"Alias '{base_alias}' not found for reference link...")
171-
match.group(0) # return original string
168+
match.group(0) # return original string
172169

173170
# Resolve the final URL and anchor
174-
url = get_relative_url(alias['url'], page_file.src_uri)
171+
url = get_relative_url(alias["url"], page_file.src_uri)
175172
if anchor:
176173
url = f"{url}#{anchor}"
177174

@@ -190,20 +187,19 @@ class AliasPlugin(BasePlugin):
190187
191188
For overridden BasePlugin methods, see the MkDocs source code.
192189
"""
190+
193191
config_scheme = (
194-
('verbose', config_options.Type(bool, default=False)),
195-
('use_anchor_titles', config_options.Type(bool, default=False)),
196-
('use_page_icon', config_options.Type(bool, default=False)),
192+
("verbose", config_options.Type(bool, default=False)),
193+
("use_anchor_titles", config_options.Type(bool, default=False)),
194+
("use_page_icon", config_options.Type(bool, default=False)),
197195
)
198196
aliases = {}
199-
log = logging.getLogger(f'mkdocs.plugins.{__name__}')
197+
log = logging.getLogger(f"mkdocs.plugins.{__name__}")
200198
current_page = None
201199

202200
def on_config(self, _):
203201
"""Set the log level if the verbose config option is set"""
204-
self.log.setLevel(
205-
logging.INFO if self.config['verbose'] else logging.WARNING
206-
)
202+
self.log.setLevel(logging.INFO if self.config["verbose"] else logging.WARNING)
207203

208204
def on_post_build(self, **_):
209205
"""Executed after the build has completed. Clears the aliases from
@@ -214,18 +210,11 @@ def on_post_build(self, **_):
214210

215211
def on_page_markdown(self, markdown: str, /, *, page: Page, **_):
216212
"""Replaces any alias tags on the page with markdown links."""
217-
self.current_page = page
218-
219213
# Replace any reference-style links first
220214
markdown = re.sub(
221215
REFERENCE_REGEX,
222-
lambda match: replace_reference(
223-
match,
224-
self.aliases,
225-
self.log,
226-
self.current_page.file
227-
),
228-
markdown
216+
lambda match: replace_reference(match, self.aliases, self.log, page.file),
217+
markdown,
229218
)
230219

231220
# Replace any inline alias tags
@@ -235,23 +224,28 @@ def on_page_markdown(self, markdown: str, /, *, page: Page, **_):
235224
match,
236225
self.aliases,
237226
self.log,
238-
self.current_page.file,
239-
self.config['use_anchor_titles']
227+
page.file,
228+
self.config["use_anchor_titles"],
240229
),
241-
markdown
230+
markdown,
242231
)
232+
self.current_page = page
243233
return markdown
244234

245235
def on_files(self, files: Files, /, **_):
246236
"""When MkDocs loads its files, extract aliases from any Markdown files
247237
that were found.
248238
"""
249239
for file in filter(lambda f: f.is_documentation_page(), files):
250-
with open(file.abs_src_path, encoding='utf-8-sig', errors='strict') as handle:
240+
if file.abs_src_path is None:
241+
continue
242+
with open(
243+
file.abs_src_path, encoding="utf-8-sig", errors="strict"
244+
) as handle:
251245
self.process_file(file, handle)
252246
# write the aliases to the aliases log file if the verbose option is set
253-
if self.config['verbose']:
254-
with open('aliases.log', 'w', encoding='utf-8') as log_file:
247+
if self.config["verbose"]:
248+
with open("aliases.log", "w", encoding="utf-8") as log_file:
255249
log_file.write("alias\ttitle\turl\n")
256250
for alias in self.aliases.values():
257251
log_file.write(
@@ -261,49 +255,45 @@ def on_files(self, files: Files, /, **_):
261255
def process_file(self, file, handle):
262256
"""Extract aliases from the given file and add them to the aliases"""
263257
source, meta_data = meta.get_data(handle.read())
264-
for section in ['alias', 'aliases']:
258+
for section in ["alias", "aliases"]:
265259
alias_names = get_alias_names(meta_data, section)
266260
if alias_names is None or len(alias_names) < 1:
267261
continue
268262

269263
# If the use_anchor_titles config option is set, parse the markdown
270264
# and get the table of contents for the page
271265
anchors: list[MarkdownAnchor] = []
272-
if self.config['use_anchor_titles']:
266+
if self.config["use_anchor_titles"]:
273267
anchors = get_markdown_toc(source)
274268

275269
if len(alias_names) > 1:
276-
self.log.info(
277-
'%s defines %d aliases:', file.url, len(alias_names)
278-
)
270+
self.log.info("%s defines %d aliases:", file.url, len(alias_names))
279271
for alias in alias_names:
280272
existing = self.aliases.get(alias)
281273
if existing is not None:
282274
self.log.warning(
283275
"%s: alias %s already defined in %s, skipping.",
284276
file.src_uri,
285277
alias,
286-
existing['url']
278+
existing["url"],
287279
)
288280
continue
289281

290282
new_alias = {
291-
'alias': alias,
292-
'text': (
293-
meta_data[section]['text']
283+
"alias": alias,
284+
"text": (
285+
meta_data[section]["text"]
294286
# if meta_data['alias'] is a dictionary and 'text' is a key
295287
if (
296-
isinstance(meta_data[section], dict) and
297-
'text' in meta_data[section]
288+
isinstance(meta_data[section], dict)
289+
and "text" in meta_data[section]
290+
)
291+
else get_page_title(
292+
source, meta_data, self.config["use_page_icon"]
298293
)
299-
else get_page_title(source, meta_data, self.config['use_page_icon'])
300294
),
301-
'url': file.src_uri,
302-
'anchors': anchors,
295+
"url": file.src_uri,
296+
"anchors": anchors,
303297
}
304-
self.log.info(
305-
"Alias %s to %s",
306-
alias,
307-
new_alias['url']
308-
)
298+
self.log.info("Alias %s to %s", alias, new_alias["url"])
309299
self.aliases[alias] = new_alias

0 commit comments

Comments
 (0)