mdformat_space_control 0.3.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.
- mdformat_space_control/__init__.py +19 -0
- mdformat_space_control/config.py +122 -0
- mdformat_space_control/plugin.py +400 -0
- mdformat_space_control-0.3.0.dist-info/METADATA +228 -0
- mdformat_space_control-0.3.0.dist-info/RECORD +8 -0
- mdformat_space_control-0.3.0.dist-info/WHEEL +4 -0
- mdformat_space_control-0.3.0.dist-info/entry_points.txt +3 -0
- mdformat_space_control-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""An mdformat plugin for space control: EditorConfig indentation, tight lists, frontmatter spacing, and wikilinks."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.3.0"
|
|
4
|
+
|
|
5
|
+
from .config import (
|
|
6
|
+
get_current_file,
|
|
7
|
+
get_indent_config,
|
|
8
|
+
set_current_file,
|
|
9
|
+
)
|
|
10
|
+
from .plugin import POSTPROCESSORS, RENDERERS, update_mdit
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"POSTPROCESSORS",
|
|
14
|
+
"RENDERERS",
|
|
15
|
+
"update_mdit",
|
|
16
|
+
"set_current_file",
|
|
17
|
+
"get_current_file",
|
|
18
|
+
"get_indent_config",
|
|
19
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""EditorConfig integration for mdformat-space-control.
|
|
2
|
+
|
|
3
|
+
This module provides functions to track the current file being formatted
|
|
4
|
+
and retrieve indentation settings from .editorconfig files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import editorconfig
|
|
11
|
+
|
|
12
|
+
# Thread-safe context variable to track the current file being formatted
|
|
13
|
+
_current_file: ContextVar[Path | None] = ContextVar("current_file", default=None)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_current_file(filepath: Path | str | None) -> None:
|
|
17
|
+
"""Set the current file being formatted (for editorconfig lookup).
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
filepath: Path to the file being formatted, or None to clear.
|
|
21
|
+
"""
|
|
22
|
+
if filepath is None:
|
|
23
|
+
_current_file.set(None)
|
|
24
|
+
else:
|
|
25
|
+
_current_file.set(Path(filepath).resolve())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_current_file() -> Path | None:
|
|
29
|
+
"""Get the current file being formatted.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Path to the current file, or None if not set.
|
|
33
|
+
"""
|
|
34
|
+
return _current_file.get()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_indent_config() -> tuple[str, int] | None:
|
|
38
|
+
"""Get indent configuration from .editorconfig for the current file.
|
|
39
|
+
|
|
40
|
+
Looks up .editorconfig settings for the current file. If no file path
|
|
41
|
+
is explicitly set (via set_current_file), falls back to using the
|
|
42
|
+
current working directory for editorconfig lookup. This enables CLI
|
|
43
|
+
usage when running mdformat from a project directory.
|
|
44
|
+
|
|
45
|
+
If CWD-based lookup finds no settings and no explicit file was set,
|
|
46
|
+
falls back to ~/.editorconfig as a final attempt. This handles cases
|
|
47
|
+
where mdformat is called from applications (like Obsidian plugins)
|
|
48
|
+
whose working directory is outside the user's HOME tree.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Tuple of (indent_style, indent_size) where:
|
|
52
|
+
- indent_style: "space" or "tab"
|
|
53
|
+
- indent_size: number of columns per indent level
|
|
54
|
+
Returns None if no indent config found.
|
|
55
|
+
"""
|
|
56
|
+
filepath = _current_file.get()
|
|
57
|
+
explicit_file_set = filepath is not None
|
|
58
|
+
|
|
59
|
+
# Fallback to cwd for CLI usage - use a synthetic .md file path
|
|
60
|
+
# to ensure markdown-specific editorconfig sections are matched
|
|
61
|
+
if filepath is None:
|
|
62
|
+
filepath = Path.cwd() / "_.md"
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
props = editorconfig.get_properties(str(filepath))
|
|
66
|
+
except editorconfig.EditorConfigError:
|
|
67
|
+
props = {}
|
|
68
|
+
|
|
69
|
+
indent_style = props.get("indent_style")
|
|
70
|
+
indent_size = props.get("indent_size")
|
|
71
|
+
|
|
72
|
+
# Fallback to ~/.editorconfig if no indent config found and no explicit file set
|
|
73
|
+
# This handles CLI usage from apps whose CWD is outside the user's HOME tree
|
|
74
|
+
if not indent_style and not indent_size and not explicit_file_set:
|
|
75
|
+
home_editorconfig = Path.home() / ".editorconfig"
|
|
76
|
+
if home_editorconfig.exists():
|
|
77
|
+
try:
|
|
78
|
+
# Use a synthetic .md file in HOME for the lookup
|
|
79
|
+
home_props = editorconfig.get_properties(str(Path.home() / "_.md"))
|
|
80
|
+
indent_style = home_props.get("indent_style")
|
|
81
|
+
indent_size = home_props.get("indent_size")
|
|
82
|
+
except editorconfig.EditorConfigError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
# If still neither property is set, return None (passthrough)
|
|
86
|
+
if not indent_style and not indent_size:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
# Default to "space" if only indent_size is set
|
|
90
|
+
style = indent_style or "space"
|
|
91
|
+
|
|
92
|
+
# Parse indent_size, default to 2 if not specified or invalid
|
|
93
|
+
if indent_size and indent_size.isdigit():
|
|
94
|
+
size = int(indent_size)
|
|
95
|
+
elif indent_size == "tab":
|
|
96
|
+
# Special case: indent_size = tab means use tab_width
|
|
97
|
+
tab_width = props.get("tab_width")
|
|
98
|
+
size = int(tab_width) if tab_width and tab_width.isdigit() else 4
|
|
99
|
+
else:
|
|
100
|
+
size = 2
|
|
101
|
+
|
|
102
|
+
return (style, size)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_indent_str() -> str | None:
|
|
106
|
+
"""Get the indentation string based on .editorconfig settings.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The string to use for one level of indentation:
|
|
110
|
+
- Tab character if indent_style is "tab"
|
|
111
|
+
- N spaces if indent_style is "space" (where N is indent_size)
|
|
112
|
+
Returns None if no indent config found (use default).
|
|
113
|
+
"""
|
|
114
|
+
config = get_indent_config()
|
|
115
|
+
if config is None:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
style, size = config
|
|
119
|
+
if style == "tab":
|
|
120
|
+
return "\t"
|
|
121
|
+
else:
|
|
122
|
+
return " " * size
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""mdformat-space-control plugin implementation.
|
|
2
|
+
|
|
3
|
+
Provides custom renderers that combine:
|
|
4
|
+
- EditorConfig-based indentation settings
|
|
5
|
+
- Tight list formatting with multi-paragraph awareness
|
|
6
|
+
- Frontmatter spacing normalization
|
|
7
|
+
- Trailing whitespace removal (outside code blocks)
|
|
8
|
+
- Wikilink preservation ([[link]] and ![[embed]] syntax)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import Mapping
|
|
13
|
+
|
|
14
|
+
from markdown_it import MarkdownIt
|
|
15
|
+
from markdown_it.rules_inline import StateInline
|
|
16
|
+
from mdformat.renderer import RenderContext, RenderTreeNode
|
|
17
|
+
from mdformat.renderer.typing import Postprocess, Render
|
|
18
|
+
from mdformat.renderer._context import (
|
|
19
|
+
make_render_children,
|
|
20
|
+
get_list_marker_type,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from mdformat_space_control.config import get_indent_config
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Wikilink pattern for Obsidian-style links:
|
|
27
|
+
# [[page]], [[page|alias]], [[page#heading]], [[page#^blockid]],
|
|
28
|
+
# [[#heading]], ![[embed]], ![[image.jpg]], etc.
|
|
29
|
+
# Pattern breakdown:
|
|
30
|
+
# !? - optional embed prefix
|
|
31
|
+
# \[\[ - opening [[
|
|
32
|
+
# [^\[\]|]* - target (no brackets or pipe)
|
|
33
|
+
# (?:#[^\[\]|]*)* - zero or more #heading/#^block sections
|
|
34
|
+
# (?:\|[^\[\]]+)? - optional |alias
|
|
35
|
+
# \]\] - closing ]]
|
|
36
|
+
WIKILINK_PATTERN = re.compile(r"!?\[\[([^\[\]|]*(?:#[^\[\]|]*)*)(?:\|[^\[\]]+)?\]\]")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _wikilink_rule(state: StateInline, silent: bool) -> bool:
|
|
40
|
+
"""Parse wikilinks at the current position.
|
|
41
|
+
|
|
42
|
+
Matches Obsidian-style wikilinks including:
|
|
43
|
+
- [[page]] and [[page|alias]]
|
|
44
|
+
- [[page#heading]] and [[page#^blockid]]
|
|
45
|
+
- [[#heading]] (same-page links)
|
|
46
|
+
- ![[embed]] and ![[image.jpg]]
|
|
47
|
+
|
|
48
|
+
By running before the link parser, wikilinks inside markdown link text
|
|
49
|
+
are correctly preserved rather than being extracted and duplicated.
|
|
50
|
+
"""
|
|
51
|
+
match = WIKILINK_PATTERN.match(state.src, state.pos)
|
|
52
|
+
if not match:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
if not silent:
|
|
56
|
+
token = state.push("wikilink", "", 0)
|
|
57
|
+
token.content = match.group(0)
|
|
58
|
+
|
|
59
|
+
state.pos = match.end()
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def update_mdit(mdit: MarkdownIt) -> None:
|
|
64
|
+
"""Update the markdown-it parser.
|
|
65
|
+
|
|
66
|
+
Adds wikilink parsing support for Obsidian-style [[link]] and ![[embed]]
|
|
67
|
+
syntax. The rule runs before the link parser to correctly handle wikilinks
|
|
68
|
+
that appear inside markdown link text.
|
|
69
|
+
"""
|
|
70
|
+
mdit.inline.ruler.before("link", "wikilink", _wikilink_rule)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def has_multiple_paragraphs(list_item_node: RenderTreeNode) -> bool:
|
|
74
|
+
"""Check if a list item has multiple paragraphs."""
|
|
75
|
+
paragraph_count = 0
|
|
76
|
+
for child in list_item_node.children:
|
|
77
|
+
if child.type == "paragraph":
|
|
78
|
+
paragraph_count += 1
|
|
79
|
+
if paragraph_count > 1:
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def list_has_loose_items(list_node: RenderTreeNode) -> bool:
|
|
85
|
+
"""Check if any item in the list has multiple paragraphs."""
|
|
86
|
+
for item in list_node.children:
|
|
87
|
+
if item.type == "list_item" and has_multiple_paragraphs(item):
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _get_indent(default_width: int) -> tuple[str, int]:
|
|
93
|
+
"""Get the indentation string and width based on editorconfig.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
default_width: The default indent width (from marker length).
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Tuple of (indent_string, indent_width) where:
|
|
100
|
+
- indent_string: The string to use for indentation
|
|
101
|
+
- indent_width: The width in columns (for context.indented)
|
|
102
|
+
"""
|
|
103
|
+
config = get_indent_config()
|
|
104
|
+
if config is None:
|
|
105
|
+
# No editorconfig - use default (passthrough behavior)
|
|
106
|
+
return (" " * default_width, default_width)
|
|
107
|
+
|
|
108
|
+
style, size = config
|
|
109
|
+
if style == "tab":
|
|
110
|
+
return ("\t", size) # Tab char, but track column width
|
|
111
|
+
else:
|
|
112
|
+
return (" " * size, size)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _render_list_item(node: RenderTreeNode, context: RenderContext) -> str:
|
|
116
|
+
"""Render a list item with appropriate tight/loose formatting.
|
|
117
|
+
|
|
118
|
+
For single-paragraph items in tight lists, use tight formatting.
|
|
119
|
+
For multi-paragraph items, preserve loose formatting.
|
|
120
|
+
"""
|
|
121
|
+
# Check if this item has multiple paragraphs
|
|
122
|
+
if has_multiple_paragraphs(node):
|
|
123
|
+
# Use loose list formatting for multi-paragraph items
|
|
124
|
+
block_separator = "\n\n"
|
|
125
|
+
else:
|
|
126
|
+
# Check if we're in a loose list (any item has multiple paragraphs)
|
|
127
|
+
parent = node.parent
|
|
128
|
+
if parent and list_has_loose_items(parent):
|
|
129
|
+
# Even single paragraph items get loose formatting in a loose list
|
|
130
|
+
block_separator = "\n\n"
|
|
131
|
+
else:
|
|
132
|
+
# Use tight formatting
|
|
133
|
+
block_separator = "\n"
|
|
134
|
+
|
|
135
|
+
text = make_render_children(block_separator)(node, context)
|
|
136
|
+
|
|
137
|
+
if not text.strip():
|
|
138
|
+
return ""
|
|
139
|
+
return text
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _render_bullet_list(node: RenderTreeNode, context: RenderContext) -> str:
|
|
143
|
+
"""Render bullet list with configurable indentation and tight formatting."""
|
|
144
|
+
marker_type = get_list_marker_type(node)
|
|
145
|
+
first_line_indent = " "
|
|
146
|
+
default_indent_width = len(marker_type + first_line_indent)
|
|
147
|
+
|
|
148
|
+
# Get configurable indent from editorconfig
|
|
149
|
+
indent_str, indent_width = _get_indent(default_indent_width)
|
|
150
|
+
|
|
151
|
+
# Determine tight/loose based on multi-paragraph items
|
|
152
|
+
is_loose = list_has_loose_items(node)
|
|
153
|
+
block_separator = "\n\n" if is_loose else "\n"
|
|
154
|
+
|
|
155
|
+
with context.indented(indent_width):
|
|
156
|
+
text = ""
|
|
157
|
+
for child_idx, child in enumerate(node.children):
|
|
158
|
+
list_item = child.render(context)
|
|
159
|
+
formatted_lines = []
|
|
160
|
+
line_iterator = iter(list_item.split("\n"))
|
|
161
|
+
first_line = next(line_iterator, "")
|
|
162
|
+
formatted_lines.append(
|
|
163
|
+
f"{marker_type}{first_line_indent}{first_line}"
|
|
164
|
+
if first_line
|
|
165
|
+
else marker_type
|
|
166
|
+
)
|
|
167
|
+
for line in line_iterator:
|
|
168
|
+
formatted_lines.append(f"{indent_str}{line}" if line else "")
|
|
169
|
+
text += "\n".join(formatted_lines)
|
|
170
|
+
if child_idx < len(node.children) - 1:
|
|
171
|
+
text += block_separator
|
|
172
|
+
return text
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _render_ordered_list(node: RenderTreeNode, context: RenderContext) -> str:
|
|
176
|
+
"""Render ordered list with configurable indentation and tight formatting."""
|
|
177
|
+
first_line_indent = " "
|
|
178
|
+
list_len = len(node.children)
|
|
179
|
+
starting_number = node.attrs.get("start")
|
|
180
|
+
if starting_number is None:
|
|
181
|
+
starting_number = 1
|
|
182
|
+
assert isinstance(starting_number, int)
|
|
183
|
+
|
|
184
|
+
# Determine tight/loose based on multi-paragraph items
|
|
185
|
+
is_loose = list_has_loose_items(node)
|
|
186
|
+
block_separator = "\n\n" if is_loose else "\n"
|
|
187
|
+
|
|
188
|
+
# Calculate default indent width based on longest marker
|
|
189
|
+
longest_marker_len = len(
|
|
190
|
+
str(starting_number + list_len - 1) + "." + first_line_indent
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Get configurable indent from editorconfig
|
|
194
|
+
indent_str, indent_width = _get_indent(longest_marker_len)
|
|
195
|
+
|
|
196
|
+
with context.indented(indent_width):
|
|
197
|
+
text = ""
|
|
198
|
+
for child_idx, child in enumerate(node.children):
|
|
199
|
+
list_marker = f"{starting_number + child_idx}."
|
|
200
|
+
|
|
201
|
+
list_item = child.render(context)
|
|
202
|
+
formatted_lines = []
|
|
203
|
+
line_iterator = iter(list_item.split("\n"))
|
|
204
|
+
first_line = next(line_iterator, "")
|
|
205
|
+
formatted_lines.append(
|
|
206
|
+
f"{list_marker}{first_line_indent}{first_line}"
|
|
207
|
+
if first_line
|
|
208
|
+
else list_marker
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
for line in line_iterator:
|
|
212
|
+
formatted_lines.append(f"{indent_str}{line}" if line else "")
|
|
213
|
+
|
|
214
|
+
text += "\n".join(formatted_lines)
|
|
215
|
+
if child_idx < len(node.children) - 1:
|
|
216
|
+
text += block_separator
|
|
217
|
+
return text
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _render_wikilink(node: RenderTreeNode, context: RenderContext) -> str:
|
|
221
|
+
"""Render a wikilink token, preserving it unchanged."""
|
|
222
|
+
return node.content
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _render_softbreak(node: RenderTreeNode, context: RenderContext) -> str:
|
|
226
|
+
"""Render soft breaks with terminal backslash.
|
|
227
|
+
|
|
228
|
+
Converts soft breaks (plain newlines within paragraphs) to hard breaks
|
|
229
|
+
(backslash + newline) to preserve visible line-break rendering and avoid
|
|
230
|
+
relying on trailing whitespace.
|
|
231
|
+
"""
|
|
232
|
+
return "\\" + "\n"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# A mapping from syntax tree node type to a function that renders it.
|
|
236
|
+
# This can be used to overwrite renderer functions of existing syntax
|
|
237
|
+
# or add support for new syntax.
|
|
238
|
+
RENDERERS: Mapping[str, Render] = {
|
|
239
|
+
"list_item": _render_list_item,
|
|
240
|
+
"bullet_list": _render_bullet_list,
|
|
241
|
+
"ordered_list": _render_ordered_list,
|
|
242
|
+
"wikilink": _render_wikilink,
|
|
243
|
+
"softbreak": _render_softbreak,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _repair_escaped_links(text: str) -> str:
|
|
248
|
+
"""Repair escaped markdown links that span multiple lines.
|
|
249
|
+
|
|
250
|
+
mdformat escapes brackets when links are malformed (contain newlines).
|
|
251
|
+
This function detects these patterns and reconstructs valid links by:
|
|
252
|
+
- Removing newlines immediately after \\[
|
|
253
|
+
- Removing newlines immediately before \\](
|
|
254
|
+
- Unescaping remaining bracket escapes to form valid links
|
|
255
|
+
|
|
256
|
+
Pattern: \\[content\\](url) spanning multiple lines
|
|
257
|
+
Result: [content](url) with internal newlines preserved
|
|
258
|
+
|
|
259
|
+
Handles standard markdown link syntax including image embeds inside links,
|
|
260
|
+
which is common in web-clipped content.
|
|
261
|
+
"""
|
|
262
|
+
# Step 1: Remove newlines immediately after escaped opening bracket
|
|
263
|
+
# \[ followed by one or more newlines → [
|
|
264
|
+
text = re.sub(r"\\\[\n+", "[", text)
|
|
265
|
+
|
|
266
|
+
# Step 2: Remove newlines immediately before escaped closing bracket with URL
|
|
267
|
+
# one or more newlines followed by \]( → ](
|
|
268
|
+
text = re.sub(r"\n+\\\]\(", "](", text)
|
|
269
|
+
|
|
270
|
+
# Step 3: Unescape \]( that follows non-backslash character
|
|
271
|
+
# This handles cases where only the opening had newlines
|
|
272
|
+
text = re.sub(r"(?<=[^\\\n])\\\]\(", "](", text)
|
|
273
|
+
|
|
274
|
+
# Step 4: Unescape \[ at start of a repaired link
|
|
275
|
+
# Pattern: \[ followed by any content ending with ](
|
|
276
|
+
# This handles cases where only the closing had newlines
|
|
277
|
+
# Use non-greedy match to find the first ]( after \[
|
|
278
|
+
text = re.sub(r"\\\[(.+?)\]\(", r"[\1](", text)
|
|
279
|
+
|
|
280
|
+
return text
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _normalize_frontmatter_spacing(text: str) -> str:
|
|
284
|
+
"""Normalize spacing after YAML frontmatter.
|
|
285
|
+
|
|
286
|
+
Removes all blank lines between frontmatter closing delimiter and the
|
|
287
|
+
first content block, producing tight spacing universally.
|
|
288
|
+
|
|
289
|
+
IMPORTANT: Only matches actual frontmatter (document starts with ---)
|
|
290
|
+
not thematic breaks appearing mid-document.
|
|
291
|
+
"""
|
|
292
|
+
# Only process if document starts with frontmatter opening delimiter
|
|
293
|
+
if not text.startswith("---\n"):
|
|
294
|
+
return text
|
|
295
|
+
|
|
296
|
+
# Find the closing delimiter (second --- on its own line)
|
|
297
|
+
# Pattern: opening --- at start, content, closing --- on its own line
|
|
298
|
+
frontmatter_match = re.match(r"^---\n.*?\n(---\n)", text, flags=re.DOTALL)
|
|
299
|
+
if not frontmatter_match:
|
|
300
|
+
return text
|
|
301
|
+
|
|
302
|
+
# Get position after closing delimiter
|
|
303
|
+
closing_end = frontmatter_match.end(1)
|
|
304
|
+
before_content = text[:closing_end]
|
|
305
|
+
after_content = text[closing_end:]
|
|
306
|
+
|
|
307
|
+
# Remove all blank lines after frontmatter (tight spacing for any content)
|
|
308
|
+
after_content = re.sub(r"^\n+", "", after_content)
|
|
309
|
+
|
|
310
|
+
return before_content + after_content
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _strip_trailing_whitespace(text: str) -> str:
|
|
314
|
+
"""Strip trailing whitespace, preserving code blocks.
|
|
315
|
+
|
|
316
|
+
Fenced code blocks (``` or ~~~) preserve trailing whitespace
|
|
317
|
+
since it may be semantically meaningful in code.
|
|
318
|
+
"""
|
|
319
|
+
lines = text.split("\n")
|
|
320
|
+
result = []
|
|
321
|
+
in_code_block = False
|
|
322
|
+
|
|
323
|
+
for line in lines:
|
|
324
|
+
# Track fenced code block state
|
|
325
|
+
stripped = line.lstrip()
|
|
326
|
+
if stripped.startswith("```") or stripped.startswith("~~~"):
|
|
327
|
+
in_code_block = not in_code_block
|
|
328
|
+
result.append(line.rstrip()) # Strip fence line itself
|
|
329
|
+
elif in_code_block:
|
|
330
|
+
# Preserve trailing whitespace inside code blocks
|
|
331
|
+
result.append(line)
|
|
332
|
+
else:
|
|
333
|
+
# Strip trailing whitespace everywhere else
|
|
334
|
+
result.append(line.rstrip())
|
|
335
|
+
|
|
336
|
+
return "\n".join(result)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _normalize_consecutive_blank_lines(text: str) -> str:
|
|
340
|
+
"""Limit consecutive blank lines to a maximum of 2.
|
|
341
|
+
|
|
342
|
+
Collapses runs of 3+ empty lines down to 2 empty lines (3 newlines).
|
|
343
|
+
Preserves content inside fenced code blocks.
|
|
344
|
+
"""
|
|
345
|
+
lines = text.split("\n")
|
|
346
|
+
result = []
|
|
347
|
+
in_code_block = False
|
|
348
|
+
consecutive_empty = 0
|
|
349
|
+
|
|
350
|
+
for line in lines:
|
|
351
|
+
# Track code block state
|
|
352
|
+
stripped = line.strip()
|
|
353
|
+
if stripped.startswith("```") or stripped.startswith("~~~"):
|
|
354
|
+
in_code_block = not in_code_block
|
|
355
|
+
|
|
356
|
+
if in_code_block:
|
|
357
|
+
# Preserve everything inside code blocks
|
|
358
|
+
result.append(line)
|
|
359
|
+
consecutive_empty = 0
|
|
360
|
+
elif line == "":
|
|
361
|
+
consecutive_empty += 1
|
|
362
|
+
if consecutive_empty <= 2:
|
|
363
|
+
result.append(line)
|
|
364
|
+
# else: skip this empty line (collapse)
|
|
365
|
+
else:
|
|
366
|
+
consecutive_empty = 0
|
|
367
|
+
result.append(line)
|
|
368
|
+
|
|
369
|
+
return "\n".join(result)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _postprocess_root(text: str, node: RenderTreeNode, context: RenderContext) -> str:
|
|
373
|
+
"""Combined postprocessor for all space control features.
|
|
374
|
+
|
|
375
|
+
Applies the following transformations in order:
|
|
376
|
+
1. Frontmatter spacing normalization
|
|
377
|
+
2. Escaped link repair
|
|
378
|
+
3. Consecutive blank line normalization
|
|
379
|
+
4. Trailing whitespace removal
|
|
380
|
+
"""
|
|
381
|
+
# 1. Frontmatter spacing
|
|
382
|
+
text = _normalize_frontmatter_spacing(text)
|
|
383
|
+
|
|
384
|
+
# 2. Repair escaped links (before trailing whitespace removal)
|
|
385
|
+
text = _repair_escaped_links(text)
|
|
386
|
+
|
|
387
|
+
# 3. Limit consecutive blank lines to 2
|
|
388
|
+
text = _normalize_consecutive_blank_lines(text)
|
|
389
|
+
|
|
390
|
+
# 4. Trailing whitespace removal
|
|
391
|
+
text = _strip_trailing_whitespace(text)
|
|
392
|
+
|
|
393
|
+
return text
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# A mapping from syntax tree node type to a postprocessing function.
|
|
397
|
+
# Postprocessors run after rendering and can modify the output text.
|
|
398
|
+
POSTPROCESSORS: Mapping[str, Postprocess] = {
|
|
399
|
+
"root": _postprocess_root,
|
|
400
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mdformat_space_control
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: An mdformat plugin for space control: EditorConfig indentation, tight lists, frontmatter spacing, and wikilinks.
|
|
5
|
+
Keywords: mdformat,markdown,formatter,editorconfig,indentation,lists,frontmatter,wikilink,obsidian
|
|
6
|
+
Author-email: Joseph Monaco <joe@selfmotion.net>
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: mdformat>=0.7.0
|
|
21
|
+
Requires-Dist: editorconfig>=0.12.0
|
|
22
|
+
Requires-Dist: pytest>=7.0 ; extra == "test"
|
|
23
|
+
Requires-Dist: pytest-cov>=4.0 ; extra == "test"
|
|
24
|
+
Requires-Dist: mdformat-frontmatter>=2.0.0 ; extra == "test"
|
|
25
|
+
Requires-Dist: mdformat-simple-breaks>=0.1.0 ; extra == "test"
|
|
26
|
+
Project-URL: Homepage, https://github.com/jdmonaco/mdformat-space-control
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
|
|
29
|
+
# mdformat-space-control
|
|
30
|
+
|
|
31
|
+
[![Build Status][ci-badge]][ci-link]
|
|
32
|
+
[![PyPI version][pypi-badge]][pypi-link]
|
|
33
|
+
|
|
34
|
+
An [mdformat](https://github.com/executablebooks/mdformat) plugin that provides unified control over Markdown spacing:
|
|
35
|
+
|
|
36
|
+
- **EditorConfig support**: Configure list indentation via `.editorconfig` files
|
|
37
|
+
- **Tight list formatting**: Automatically removes unnecessary blank lines between list items
|
|
38
|
+
- **Frontmatter spacing**: Normalizes spacing after YAML frontmatter (works with [mdformat-frontmatter](https://github.com/butler54/mdformat-frontmatter))
|
|
39
|
+
- **Consecutive blank line normalization**: Limits runs of 3+ empty lines to a maximum of 2
|
|
40
|
+
- **Trailing whitespace removal**: Strips trailing whitespace outside code blocks
|
|
41
|
+
- **Escaped link repair**: Fixes malformed multi-line links from web-clipped content
|
|
42
|
+
- **Wikilink preservation**: Handles Obsidian-style `[[links]]`, `[[links|aliases]]`, `[[page#heading]]`, `[[page#^blockid]]`, and `![[embeds]]`
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install mdformat-space-control
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or with [pipx](https://pipx.pypa.io/) for command-line usage:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pipx install mdformat
|
|
54
|
+
pipx inject mdformat mdformat-space-control
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
After installation, mdformat will automatically use this plugin:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
mdformat your-file.md
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### EditorConfig Support
|
|
66
|
+
|
|
67
|
+
Create an `.editorconfig` file in your project:
|
|
68
|
+
|
|
69
|
+
```ini
|
|
70
|
+
# .editorconfig
|
|
71
|
+
root = true
|
|
72
|
+
|
|
73
|
+
[*.md]
|
|
74
|
+
indent_style = space
|
|
75
|
+
indent_size = 4
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Nested lists will use the configured indentation:
|
|
79
|
+
|
|
80
|
+
**Before:**
|
|
81
|
+
```markdown
|
|
82
|
+
- Item 1
|
|
83
|
+
- Nested item
|
|
84
|
+
- Item 2
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**After (with 4-space indent):**
|
|
88
|
+
```markdown
|
|
89
|
+
- Item 1
|
|
90
|
+
- Nested item
|
|
91
|
+
- Item 2
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Tight List Formatting
|
|
95
|
+
|
|
96
|
+
Lists with single-paragraph items are automatically formatted as tight lists:
|
|
97
|
+
|
|
98
|
+
**Before:**
|
|
99
|
+
```markdown
|
|
100
|
+
- Item 1
|
|
101
|
+
|
|
102
|
+
- Item 2
|
|
103
|
+
|
|
104
|
+
- Item 3
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**After:**
|
|
108
|
+
```markdown
|
|
109
|
+
- Item 1
|
|
110
|
+
- Item 2
|
|
111
|
+
- Item 3
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Multi-paragraph items preserve loose formatting:
|
|
115
|
+
|
|
116
|
+
```markdown
|
|
117
|
+
- First item with multiple paragraphs
|
|
118
|
+
|
|
119
|
+
Second paragraph of first item
|
|
120
|
+
|
|
121
|
+
- Second item
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Frontmatter Spacing
|
|
125
|
+
|
|
126
|
+
When used with [mdformat-frontmatter](https://github.com/butler54/mdformat-frontmatter), this plugin removes blank lines between the frontmatter closing delimiter and the first content block:
|
|
127
|
+
|
|
128
|
+
**Before:**
|
|
129
|
+
```markdown
|
|
130
|
+
---
|
|
131
|
+
title: My Document
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Introduction
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**After:**
|
|
139
|
+
```markdown
|
|
140
|
+
---
|
|
141
|
+
title: My Document
|
|
142
|
+
---
|
|
143
|
+
# Introduction
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Install both plugins for this feature:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
pip install mdformat-space-control mdformat-frontmatter
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### EditorConfig Properties
|
|
153
|
+
|
|
154
|
+
| Property | Status | Notes |
|
|
155
|
+
|----------|--------|-------|
|
|
156
|
+
| `indent_style` | Supported | `space` or `tab` for list indentation |
|
|
157
|
+
| `indent_size` | Supported | Number of spaces per indent level |
|
|
158
|
+
| `tab_width` | Supported | Used when `indent_size = tab` |
|
|
159
|
+
|
|
160
|
+
### Python API
|
|
161
|
+
|
|
162
|
+
When using the Python API, you can set the file context for EditorConfig lookup:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
import mdformat
|
|
166
|
+
from mdformat_space_control import set_current_file
|
|
167
|
+
|
|
168
|
+
set_current_file("/path/to/your/file.md")
|
|
169
|
+
try:
|
|
170
|
+
result = mdformat.text(markdown_text, extensions={"space_control"})
|
|
171
|
+
finally:
|
|
172
|
+
set_current_file(None)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Wikilink Preservation
|
|
176
|
+
|
|
177
|
+
Obsidian-style wikilinks are preserved during formatting:
|
|
178
|
+
|
|
179
|
+
```markdown
|
|
180
|
+
Link to [[another note]] or [[note|with alias]].
|
|
181
|
+
Embed an image: ![[photo.jpg]]
|
|
182
|
+
Link to heading: [[note#section]]
|
|
183
|
+
Block reference: [[note#^blockid]]
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Wikilinks inside markdown link text are correctly handled without duplication:
|
|
187
|
+
|
|
188
|
+
```markdown
|
|
189
|
+
[![[image.jpg]]](http://example.com)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Compatible Plugins
|
|
193
|
+
|
|
194
|
+
This plugin is tested to work alongside:
|
|
195
|
+
|
|
196
|
+
- [mdformat-frontmatter](https://github.com/butler54/mdformat-frontmatter) - YAML frontmatter parsing
|
|
197
|
+
- [mdformat-simple-breaks](https://github.com/csala/mdformat-simple-breaks) - Normalizes thematic breaks to `---`
|
|
198
|
+
|
|
199
|
+
For formatting files in an **Obsidian vault**, installing `mdformat-frontmatter` alongside this plugin is recommended:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
pip install mdformat-space-control mdformat-frontmatter
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Note: Wikilink support is built-in; `mdformat-wikilink` is not needed.
|
|
206
|
+
|
|
207
|
+
## Development
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
# Install dependencies
|
|
211
|
+
uv sync
|
|
212
|
+
|
|
213
|
+
# Run tests
|
|
214
|
+
uv run python -m pytest
|
|
215
|
+
|
|
216
|
+
# Run with coverage
|
|
217
|
+
uv run python -m pytest --cov=mdformat_space_control
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT - see LICENSE file for details.
|
|
223
|
+
|
|
224
|
+
[ci-badge]: https://github.com/jdmonaco/mdformat-space-control/actions/workflows/tests.yml/badge.svg?branch=main
|
|
225
|
+
[ci-link]: https://github.com/jdmonaco/mdformat-space-control/actions/workflows/tests.yml
|
|
226
|
+
[pypi-badge]: https://img.shields.io/pypi/v/mdformat-space-control.svg
|
|
227
|
+
[pypi-link]: https://pypi.org/project/mdformat-space-control
|
|
228
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
mdformat_space_control/__init__.py,sha256=-rXQZCz7uYMbIv5K8sh9JCZ7icgiJGvvJtYcSGUYyYU,439
|
|
2
|
+
mdformat_space_control/config.py,sha256=b0cEeMeoBwrcYdoESTR-0A4S6oEqozXT2WbwXlCyBr4,4237
|
|
3
|
+
mdformat_space_control/plugin.py,sha256=Fv7U0UNUKgsuFpRSjAoxboIiTDlEgoaG2da4rHeIJ5Y,13950
|
|
4
|
+
mdformat_space_control-0.3.0.dist-info/entry_points.txt,sha256=GMA7LGi700M9BbMR4QlCa2ARh4k4gipa4mbU4ztdltE,66
|
|
5
|
+
mdformat_space_control-0.3.0.dist-info/licenses/LICENSE,sha256=-voxTd2luDRUXIFkSXQRQGL3cHzC9AnnJkv3LJrla3o,1073
|
|
6
|
+
mdformat_space_control-0.3.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
7
|
+
mdformat_space_control-0.3.0.dist-info/METADATA,sha256=zaY9fqus7YWKsEL5bZ_wv5eu3ZckQe2UrQwwWrhR0o0,5916
|
|
8
|
+
mdformat_space_control-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020 Executable Books
|
|
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.
|