mdformat-sembr 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.
- mdformat_sembr/__init__.py +14 -0
- mdformat_sembr/_plugin.py +133 -0
- mdformat_sembr/_sembr.py +228 -0
- mdformat_sembr/py.typed +0 -0
- mdformat_sembr-0.1.0.dist-info/METADATA +114 -0
- mdformat_sembr-0.1.0.dist-info/RECORD +9 -0
- mdformat_sembr-0.1.0.dist-info/WHEEL +4 -0
- mdformat_sembr-0.1.0.dist-info/entry_points.txt +2 -0
- mdformat_sembr-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""mdformat-sembr: Semantic Line Breaks as CommonMark soft breaks.
|
|
2
|
+
|
|
3
|
+
The entry point ``mdformat.parser_extension`` -> ``sembr`` resolves to the
|
|
4
|
+
``_plugin`` attribute of this package (see ``pyproject.toml``). Importing it here
|
|
5
|
+
exposes the interface object as ``mdformat_sembr._plugin``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from mdformat_sembr import _plugin
|
|
11
|
+
from mdformat_sembr._sembr import insert_breaks
|
|
12
|
+
|
|
13
|
+
__all__ = ["_plugin", "insert_breaks"]
|
|
14
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""mdformat parser-extension interface for the SemBr plugin.
|
|
2
|
+
|
|
3
|
+
This module *is* the plugin interface object referenced by the entry point
|
|
4
|
+
``mdformat_sembr:_plugin``. It exposes the members required by
|
|
5
|
+
``mdformat.plugins.ParserExtensionInterface`` at module level.
|
|
6
|
+
|
|
7
|
+
We do not change the parser or override any renderer; all work happens in a
|
|
8
|
+
postprocessor registered on the ``paragraph`` node type. At that point inline
|
|
9
|
+
formatting is already resolved into the rendered string, so we operate on final
|
|
10
|
+
text and protect a few inline constructs by regex.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from mdformat_sembr._sembr import (
|
|
20
|
+
DEFAULT_ABBREVIATIONS,
|
|
21
|
+
DEFAULT_CLAUSE_CHARS,
|
|
22
|
+
DEFAULT_MIN_CHARS,
|
|
23
|
+
insert_breaks,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from markdown_it import MarkdownIt
|
|
28
|
+
from mdformat.renderer import RenderContext, RenderTreeNode
|
|
29
|
+
|
|
30
|
+
#: SemBr soft breaks never alter the rendered output, so the AST is unchanged.
|
|
31
|
+
#: This lets mdformat's built-in ``is_md_equal`` validator gate correctness.
|
|
32
|
+
CHANGES_AST = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def update_mdit(mdit: "MarkdownIt") -> None:
|
|
36
|
+
"""No parser change is needed for SemBr."""
|
|
37
|
+
# Intentionally a no-op.
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _plugin_options(context: "RenderContext") -> Mapping[str, Any]:
|
|
42
|
+
"""Return the merged ``[plugin.sembr]`` / CLI options mapping."""
|
|
43
|
+
mdformat_opts = context.options.get("mdformat", {})
|
|
44
|
+
plugin_opts = mdformat_opts.get("plugin", {})
|
|
45
|
+
return plugin_opts.get("sembr", {}) or {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _postprocess_paragraph(
|
|
49
|
+
text: str,
|
|
50
|
+
node: "RenderTreeNode",
|
|
51
|
+
context: "RenderContext",
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Insert SemBr soft breaks into an already-rendered paragraph string."""
|
|
54
|
+
opts = _plugin_options(context)
|
|
55
|
+
|
|
56
|
+
min_chars = opts.get("min_chars", DEFAULT_MIN_CHARS)
|
|
57
|
+
abbreviations = opts.get("abbreviations", None)
|
|
58
|
+
break_clauses = bool(opts.get("break_clauses", False))
|
|
59
|
+
clause_chars = opts.get("clause_chars", DEFAULT_CLAUSE_CHARS)
|
|
60
|
+
|
|
61
|
+
return insert_breaks(
|
|
62
|
+
text,
|
|
63
|
+
min_chars=int(min_chars),
|
|
64
|
+
abbreviations=abbreviations,
|
|
65
|
+
break_clauses=break_clauses,
|
|
66
|
+
clause_chars=clause_chars,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def add_cli_argument_group(group: argparse._ArgumentGroup) -> None:
|
|
71
|
+
"""Register CLI options, mirrored to TOML ``[plugin.sembr]``.
|
|
72
|
+
|
|
73
|
+
Values are stored under ``mdit.options["mdformat"]["plugin"]["sembr"]`` and
|
|
74
|
+
merged with the TOML config. ``dest`` names deliberately match the TOML keys
|
|
75
|
+
so CLI values merge cleanly over TOML.
|
|
76
|
+
"""
|
|
77
|
+
group.add_argument(
|
|
78
|
+
"--sembr-min-chars",
|
|
79
|
+
dest="min_chars",
|
|
80
|
+
type=int,
|
|
81
|
+
default=None,
|
|
82
|
+
metavar="N",
|
|
83
|
+
help=(
|
|
84
|
+
"minimum length of the segment before a break is allowed "
|
|
85
|
+
f"(default: {DEFAULT_MIN_CHARS})"
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
group.add_argument(
|
|
89
|
+
"--sembr-abbreviations",
|
|
90
|
+
dest="abbreviations",
|
|
91
|
+
action="append",
|
|
92
|
+
default=None,
|
|
93
|
+
metavar="ABBR",
|
|
94
|
+
help=(
|
|
95
|
+
"abbreviation after which no sentence break is inserted; "
|
|
96
|
+
"repeat to add several (replaces the default list)"
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
group.add_argument(
|
|
100
|
+
"--sembr-break-clauses",
|
|
101
|
+
dest="break_clauses",
|
|
102
|
+
action="store_true",
|
|
103
|
+
default=None,
|
|
104
|
+
help="also break after clause punctuation (Iteration 2; off by default)",
|
|
105
|
+
)
|
|
106
|
+
group.add_argument(
|
|
107
|
+
"--sembr-clause-chars",
|
|
108
|
+
dest="clause_chars",
|
|
109
|
+
default=None,
|
|
110
|
+
metavar="CHARS",
|
|
111
|
+
help=(
|
|
112
|
+
"clause punctuation set used when --sembr-break-clauses is on "
|
|
113
|
+
f"(default: {DEFAULT_CLAUSE_CHARS!r})"
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
#: A mapping from ``RenderTreeNode.type`` to a ``Render`` function. Empty: we do
|
|
119
|
+
#: not override rendering.
|
|
120
|
+
RENDERERS: Mapping[str, Any] = {}
|
|
121
|
+
|
|
122
|
+
#: A mapping from ``RenderTreeNode.type`` to a collaborative ``Postprocess``.
|
|
123
|
+
POSTPROCESSORS: Mapping[str, Any] = {"paragraph": _postprocess_paragraph}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = [
|
|
127
|
+
"CHANGES_AST",
|
|
128
|
+
"RENDERERS",
|
|
129
|
+
"POSTPROCESSORS",
|
|
130
|
+
"update_mdit",
|
|
131
|
+
"add_cli_argument_group",
|
|
132
|
+
"DEFAULT_ABBREVIATIONS",
|
|
133
|
+
]
|
mdformat_sembr/_sembr.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Deterministic Semantic Line Break (sembr.org) insertion.
|
|
2
|
+
|
|
3
|
+
This module holds the pure, rule-based break logic. It is intentionally free of
|
|
4
|
+
any mdformat imports so it can be unit-tested in isolation and reused by the
|
|
5
|
+
plugin's paragraph postprocessor.
|
|
6
|
+
|
|
7
|
+
The sentence-boundary regex, abbreviation list, inline-code masking and
|
|
8
|
+
abbreviation guard are ported from the project's original
|
|
9
|
+
``.github/hooks/markdown-format/markdown-format.py`` script.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Defaults (ported from the original markdown-format.py hook script)
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
#: Minimum length of the segment preceding a break. Prevents splitting short
|
|
22
|
+
#: enumerations and fragments.
|
|
23
|
+
DEFAULT_MIN_CHARS = 15
|
|
24
|
+
|
|
25
|
+
#: Clause punctuation used by Iteration 2 (only when ``break_clauses`` is on).
|
|
26
|
+
DEFAULT_CLAUSE_CHARS = ",;:\u2014" # comma, semicolon, colon, em dash
|
|
27
|
+
|
|
28
|
+
#: Common abbreviations whose trailing dot must NOT end a sentence.
|
|
29
|
+
DEFAULT_ABBREVIATIONS: frozenset[str] = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"e.g", "i.e", "etc", "vs", "cf", "viz", "al", "approx", "incl", "excl",
|
|
32
|
+
"Mr", "Mrs", "Ms", "Dr", "Prof", "Sr", "Jr", "St",
|
|
33
|
+
"Inc", "Ltd", "Co", "Corp", "U.S", "U.K", "U.N", "E.U",
|
|
34
|
+
"Fig", "fig", "no", "No", "vol", "Vol", "ch", "Ch",
|
|
35
|
+
"p", "pp", "para", "sec", "Sect",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Regexes
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
# Sentence-terminator (. ! ?) — including "?!"/"!?" runs — followed by an
|
|
44
|
+
# optional closing quote/bracket, whitespace, and a likely sentence start.
|
|
45
|
+
# The negative lookbehind avoids breaking inside an ellipsis ("...").
|
|
46
|
+
_SENTENCE_BOUNDARY = re.compile(
|
|
47
|
+
r'(?<=[.!?])(?<!\.\.\.)["\')\]]?\s+(?=["\'(\[`*_]?[A-Z0-9])'
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Placeholder markers use NUL bytes which never occur in Markdown source text.
|
|
51
|
+
_PLACEHOLDER = "\x00{kind}{index}\x00"
|
|
52
|
+
_PLACEHOLDER_RE = re.compile(r"\x00([A-Z]+)(\d+)\x00")
|
|
53
|
+
|
|
54
|
+
# Protected inline constructs. Order matters: images/links before bare code so
|
|
55
|
+
# a link label containing backticks is masked as one unit.
|
|
56
|
+
_PROTECTED_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
57
|
+
# Inline code spans: one or more backticks, no embedded newline.
|
|
58
|
+
("CODE", re.compile(r"`+[^`\n]*`+")),
|
|
59
|
+
# Images and links: optional leading '!', label in [...], target in (...).
|
|
60
|
+
("LINK", re.compile(r"!?\[[^\]\n]*\]\([^)\n]*\)")),
|
|
61
|
+
# Reference-style links / footnote references: [text][id] or [^id].
|
|
62
|
+
("REF", re.compile(r"!?\[[^\]\n]*\]\[[^\]\n]*\]")),
|
|
63
|
+
("FOOT", re.compile(r"\[\^[^\]\n]+\]")),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Masking of protected regions
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def _mask(text: str) -> tuple[str, dict[str, str]]:
|
|
72
|
+
"""Replace protected inline spans with opaque NUL-delimited tokens.
|
|
73
|
+
|
|
74
|
+
Returns the masked text and a mapping of token -> original span, so the
|
|
75
|
+
break regex can never match inside code, links, images or footnote refs.
|
|
76
|
+
"""
|
|
77
|
+
store: dict[str, str] = {}
|
|
78
|
+
counter = 0
|
|
79
|
+
|
|
80
|
+
for kind, pattern in _PROTECTED_PATTERNS:
|
|
81
|
+
|
|
82
|
+
def repl(m: re.Match[str], _kind: str = kind) -> str:
|
|
83
|
+
nonlocal counter
|
|
84
|
+
token = _PLACEHOLDER.format(kind=_kind, index=counter)
|
|
85
|
+
store[token] = m.group(0)
|
|
86
|
+
counter += 1
|
|
87
|
+
return token
|
|
88
|
+
|
|
89
|
+
text = pattern.sub(repl, text)
|
|
90
|
+
|
|
91
|
+
return text, store
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _unmask(text: str, store: dict[str, str]) -> str:
|
|
95
|
+
"""Reverse :func:`_mask`, restoring original spans from placeholder tokens."""
|
|
96
|
+
if not store:
|
|
97
|
+
return text
|
|
98
|
+
|
|
99
|
+
def repl(m: re.Match[str]) -> str:
|
|
100
|
+
return store.get(m.group(0), m.group(0))
|
|
101
|
+
|
|
102
|
+
# Repeat until stable in case a restored span contained another token
|
|
103
|
+
# (protected spans never nest in practice, but this keeps it robust).
|
|
104
|
+
prev = None
|
|
105
|
+
while prev != text:
|
|
106
|
+
prev = text
|
|
107
|
+
text = _PLACEHOLDER_RE.sub(repl, text)
|
|
108
|
+
return text
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Abbreviation guard
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _is_abbreviation_before(text: str, idx: int, abbreviations: frozenset[str]) -> bool:
|
|
116
|
+
"""Return True if the period just before ``idx`` belongs to an abbreviation."""
|
|
117
|
+
j = idx - 1
|
|
118
|
+
if j < 0 or text[j] != ".":
|
|
119
|
+
return False
|
|
120
|
+
start = j
|
|
121
|
+
while start > 0 and (text[start - 1].isalnum() or text[start - 1] == "."):
|
|
122
|
+
start -= 1
|
|
123
|
+
token = text[start:j] # word chars before the trailing dot
|
|
124
|
+
return token in abbreviations
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Core break logic
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def _collapse_whitespace(text: str) -> str:
|
|
132
|
+
"""Collapse all runs of whitespace (including newlines) to single spaces.
|
|
133
|
+
|
|
134
|
+
Collapse-then-rebreak is what makes the transform deterministic and
|
|
135
|
+
idempotent regardless of any existing soft breaks in the input.
|
|
136
|
+
"""
|
|
137
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _split_points(masked: str, abbreviations: frozenset[str]) -> list[int]:
|
|
141
|
+
"""Return sorted cut indices for sentence boundaries in ``masked`` text.
|
|
142
|
+
|
|
143
|
+
Each index is the position *after* which a break should be inserted (i.e.
|
|
144
|
+
the start of the whitespace run following a sentence terminator).
|
|
145
|
+
"""
|
|
146
|
+
points: list[int] = []
|
|
147
|
+
for m in _SENTENCE_BOUNDARY.finditer(masked):
|
|
148
|
+
if _is_abbreviation_before(masked, m.start(), abbreviations):
|
|
149
|
+
continue
|
|
150
|
+
points.append(m.start())
|
|
151
|
+
return points
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _clause_split_points(masked: str, clause_chars: str) -> list[int]:
|
|
155
|
+
"""Return cut indices after independent-clause punctuation."""
|
|
156
|
+
if not clause_chars:
|
|
157
|
+
return []
|
|
158
|
+
escaped = re.escape(clause_chars)
|
|
159
|
+
# Clause punctuation followed by whitespace and a non-space continuation.
|
|
160
|
+
pattern = re.compile(rf"(?<=[{escaped}])\s+(?=\S)")
|
|
161
|
+
return [m.start() for m in pattern.finditer(masked)]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _apply_breaks(masked: str, cut_points: Iterable[int], min_chars: int) -> str:
|
|
165
|
+
"""Insert newlines at ``cut_points`` subject to the ``min_chars`` threshold.
|
|
166
|
+
|
|
167
|
+
The threshold is measured against the current line segment: a break is only
|
|
168
|
+
inserted if the text since the previous break is at least ``min_chars`` long.
|
|
169
|
+
"""
|
|
170
|
+
unique_points = sorted(set(cut_points))
|
|
171
|
+
if not unique_points:
|
|
172
|
+
return masked
|
|
173
|
+
|
|
174
|
+
out: list[str] = []
|
|
175
|
+
last = 0
|
|
176
|
+
line_start = 0
|
|
177
|
+
for point in unique_points:
|
|
178
|
+
segment_len = len(masked[line_start:point].strip())
|
|
179
|
+
if segment_len < min_chars:
|
|
180
|
+
continue
|
|
181
|
+
out.append(masked[last:point].rstrip())
|
|
182
|
+
out.append("\n")
|
|
183
|
+
# Skip the whitespace run that followed the boundary.
|
|
184
|
+
next_start = point
|
|
185
|
+
while next_start < len(masked) and masked[next_start].isspace():
|
|
186
|
+
next_start += 1
|
|
187
|
+
last = next_start
|
|
188
|
+
line_start = next_start
|
|
189
|
+
out.append(masked[last:])
|
|
190
|
+
return "".join(out)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def insert_breaks(
|
|
194
|
+
text: str,
|
|
195
|
+
*,
|
|
196
|
+
min_chars: int = DEFAULT_MIN_CHARS,
|
|
197
|
+
abbreviations: Iterable[str] | None = None,
|
|
198
|
+
break_clauses: bool = False,
|
|
199
|
+
clause_chars: str = DEFAULT_CLAUSE_CHARS,
|
|
200
|
+
) -> str:
|
|
201
|
+
"""Insert SemBr soft breaks into a single rendered paragraph string.
|
|
202
|
+
|
|
203
|
+
Sentence boundaries (``.``/``!``/``?``) always break (Iteration 1). When
|
|
204
|
+
``break_clauses`` is true, clause punctuation in ``clause_chars`` also breaks
|
|
205
|
+
(Iteration 2). Protected inline regions (code, links, images, footnote refs)
|
|
206
|
+
and abbreviations are never split.
|
|
207
|
+
|
|
208
|
+
Only bare ``\\n`` soft breaks are emitted — never hard breaks. Rendered HTML
|
|
209
|
+
output is therefore unchanged. The transform is deterministic and idempotent.
|
|
210
|
+
"""
|
|
211
|
+
abbrev = (
|
|
212
|
+
DEFAULT_ABBREVIATIONS
|
|
213
|
+
if abbreviations is None
|
|
214
|
+
else frozenset(abbreviations)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
collapsed = _collapse_whitespace(text)
|
|
218
|
+
if not collapsed:
|
|
219
|
+
return collapsed
|
|
220
|
+
|
|
221
|
+
masked, store = _mask(collapsed)
|
|
222
|
+
|
|
223
|
+
cut_points = _split_points(masked, abbrev)
|
|
224
|
+
if break_clauses:
|
|
225
|
+
cut_points += _clause_split_points(masked, clause_chars)
|
|
226
|
+
|
|
227
|
+
broken = _apply_breaks(masked, cut_points, min_chars)
|
|
228
|
+
return _unmask(broken, store)
|
mdformat_sembr/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mdformat-sembr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: mdformat plugin that inserts Semantic Line Breaks (sembr.org) as CommonMark soft breaks
|
|
5
|
+
Project-URL: Homepage, https://codeberg.org/bugrasan/mdformat-sembr
|
|
6
|
+
Project-URL: GitHub Mirror, https://github.com/bugrasan/mdformat-sembr
|
|
7
|
+
Author: bugrasan
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: formatter,markdown,mdformat,semantic line breaks,sembr
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
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
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: mdformat>=1.0
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# mdformat-sembr
|
|
26
|
+
|
|
27
|
+
An [mdformat](https://mdformat.readthedocs.io) parser-extension plugin that inserts
|
|
28
|
+
[Semantic Line Breaks](https://sembr.org) (SemBr) as CommonMark **soft breaks**.
|
|
29
|
+
|
|
30
|
+
SemBr is a convention for adding line breaks in Markdown source at sentence and
|
|
31
|
+
clause boundaries. Because the breaks are CommonMark *soft* breaks (a bare `\n`
|
|
32
|
+
inside a paragraph), they render to a single space — the rendered HTML output is
|
|
33
|
+
unchanged, only the source becomes more diff-friendly.
|
|
34
|
+
|
|
35
|
+
The plugin is fully deterministic: no ML, no network, no LLM calls. The same input
|
|
36
|
+
always produces the same output.
|
|
37
|
+
|
|
38
|
+
## Why
|
|
39
|
+
|
|
40
|
+
Moving SemBr logic out of an LLM/agent loop into a token-free, reproducible
|
|
41
|
+
formatter pass makes authored Markdown consistent and cheap to maintain.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# uv (recommended) — --with is repeatable (or comma-separate the plugins)
|
|
47
|
+
uv tool install mdformat --with mdformat-sembr --with mdformat-frontmatter
|
|
48
|
+
|
|
49
|
+
# pipx — install the app, then inject the plugins into its environment
|
|
50
|
+
pipx install mdformat
|
|
51
|
+
pipx inject mdformat mdformat-sembr mdformat-frontmatter
|
|
52
|
+
|
|
53
|
+
# local development
|
|
54
|
+
pip install -e .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`mdformat-frontmatter` is optional: install it only if your Markdown uses
|
|
58
|
+
YAML/TOML frontmatter and you want mdformat to preserve/format it. It composes
|
|
59
|
+
with `mdformat-sembr` (frontmatter is a separate node type and is never broken).
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
mdformat --version # should list "mdformat_sembr"
|
|
65
|
+
echo "First sentence. Second sentence." | mdformat -
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
From Python:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import mdformat
|
|
72
|
+
|
|
73
|
+
mdformat.text("First sentence. Second sentence.\n", extensions={"sembr"})
|
|
74
|
+
# 'First sentence.\nSecond sentence.\n'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## How it works
|
|
78
|
+
|
|
79
|
+
The plugin registers a **postprocessor** on the `paragraph` node type. At that point
|
|
80
|
+
inline formatting (emphasis, links, inline code) is already resolved into the string,
|
|
81
|
+
so it operates on the final rendered text and only protects a few inline constructs by
|
|
82
|
+
regex. Block-level elements (headings, code blocks, tables, frontmatter, HTML blocks)
|
|
83
|
+
are separate node types and are never touched.
|
|
84
|
+
|
|
85
|
+
`CHANGES_AST = False`: soft breaks are AST-safe by design, so mdformat's built-in
|
|
86
|
+
`is_md_equal` validator gates correctness. If validation ever fails, the break logic is
|
|
87
|
+
wrong — it is never worked around with `--no-validate` or hard breaks.
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
Configure via `[plugin.sembr]` in `.mdformat.toml`, or via CLI flags. CLI values merge
|
|
92
|
+
over TOML.
|
|
93
|
+
|
|
94
|
+
| Option | Type | Default | Meaning |
|
|
95
|
+
| --------------- | ----------- | --------- | -------------------------------------------------------------- |
|
|
96
|
+
| `min_chars` | int | `15` | Minimum length of the segment before a break is allowed. |
|
|
97
|
+
| `abbreviations` | list[str] | see below | Tokens after which no sentence break is inserted. |
|
|
98
|
+
| `break_clauses` | bool | `false` | Enable clause-level breaks (SemBr "SHOULD"). Off by default. |
|
|
99
|
+
| `clause_chars` | str | `",;:—"` | Clause punctuation set (only used when `break_clauses` true). |
|
|
100
|
+
|
|
101
|
+
CLI flags: `--sembr-min-chars`, `--sembr-abbreviations`, `--sembr-break-clauses`,
|
|
102
|
+
`--sembr-clause-chars`.
|
|
103
|
+
|
|
104
|
+
`.mdformat.toml` example:
|
|
105
|
+
|
|
106
|
+
```toml
|
|
107
|
+
[plugin.sembr]
|
|
108
|
+
min_chars = 20
|
|
109
|
+
break_clauses = true
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
mdformat_sembr/__init__.py,sha256=2angvPWFxzexnHG8AEkcDZIK6y88YAkcef9qWhQPUqU,471
|
|
2
|
+
mdformat_sembr/_plugin.py,sha256=Mm3godzv0eUJHYLaGDehvz7led88L1bjnDHwMaqWiyo,4119
|
|
3
|
+
mdformat_sembr/_sembr.py,sha256=ouOVnHJTGvnw8-iR6_1UdySYhcAyuoTYQZ1trr3EbSE,8493
|
|
4
|
+
mdformat_sembr/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
mdformat_sembr-0.1.0.dist-info/METADATA,sha256=8rydE6eWbkXL92uxa2Kd44opWsxiOqoGt4-3PCd4W2o,4314
|
|
6
|
+
mdformat_sembr-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
mdformat_sembr-0.1.0.dist-info/entry_points.txt,sha256=cZs7zd0X63vlwUoEMiZQzx_C30mo6WXjcCHiqLnx1ko,59
|
|
8
|
+
mdformat_sembr-0.1.0.dist-info/licenses/LICENSE,sha256=xEWxditjeckt1SLFDqL2rKbJaqS72hzBGez4K1QSxYI,1084
|
|
9
|
+
mdformat_sembr-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mdformat-sembr contributors
|
|
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.
|