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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [mdformat.parser_extension]
2
+ space_control=mdformat_space_control
3
+
@@ -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.