flowmark 0.3.3__tar.gz → 0.4.1__tar.gz
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.
- {flowmark-0.3.3 → flowmark-0.4.1}/.copier-answers.yml +1 -1
- {flowmark-0.3.3 → flowmark-0.4.1}/.github/workflows/ci.yml +2 -1
- {flowmark-0.3.3 → flowmark-0.4.1}/.github/workflows/publish.yml +1 -1
- {flowmark-0.3.3 → flowmark-0.4.1}/Makefile +2 -1
- {flowmark-0.3.3 → flowmark-0.4.1}/PKG-INFO +16 -2
- {flowmark-0.3.3 → flowmark-0.4.1}/README.md +6 -1
- {flowmark-0.3.3 → flowmark-0.4.1}/devtools/lint.py +7 -3
- flowmark-0.4.1/installation.md +27 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/pyproject.toml +44 -7
- flowmark-0.4.1/src/flowmark/cleanups.py +76 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/src/flowmark/cli.py +10 -0
- flowmark-0.3.3/src/flowmark/markdown_filling.py → flowmark-0.4.1/src/flowmark/custom_marko.py +68 -116
- flowmark-0.4.1/src/flowmark/markdown_filling.py +132 -0
- flowmark-0.4.1/src/flowmark/py.typed +0 -0
- flowmark-0.4.1/tests/test_cleanups.py +62 -0
- flowmark-0.4.1/tests/test_ref_docs.py +48 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/tests/testdocs/testdoc.orig.md +33 -4
- flowmark-0.3.3/tests/testdocs/testdoc.out.semantic.md → flowmark-0.4.1/tests/testdocs/testdoc.out.cleaned.md +36 -10
- {flowmark-0.3.3 → flowmark-0.4.1}/tests/testdocs/testdoc.out.plain.md +36 -10
- flowmark-0.4.1/tests/testdocs/testdoc.out.semantic.md +944 -0
- flowmark-0.3.3/tests/test_ref_docs.py +0 -47
- {flowmark-0.3.3 → flowmark-0.4.1}/.gitignore +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/LICENSE +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/development.md +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/poetry.lock +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/publishing.md +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/src/flowmark/__init__.py +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/src/flowmark/frontmatter.py +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/src/flowmark/line_wrappers.py +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/src/flowmark/sentence_split_regex.py +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/src/flowmark/text_filling.py +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/src/flowmark/text_wrapping.py +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/tests/test_filling.py +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/tests/test_frontmatter.py +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/tests/test_sentences.py +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/tests/test_wrapping.py +0 -0
- {flowmark-0.3.3 → flowmark-0.4.1}/uv.lock +0 -0
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flowmark
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Better line wrapping and formatting for plaintext and Markdown
|
|
5
5
|
Project-URL: Repository, https://github.com/jlevy/flowmark
|
|
6
6
|
Author-email: Joshua Levy <joshua@cal.berkeley.edu>
|
|
7
7
|
License-Expression: MIT
|
|
8
8
|
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Typing :: Typed
|
|
9
18
|
Requires-Python: <4.0,>=3.10
|
|
10
19
|
Requires-Dist: marko>=2.1.2
|
|
11
20
|
Requires-Dist: regex>=2024.11.6
|
|
@@ -169,7 +178,6 @@ Markdown content. It can:
|
|
|
169
178
|
|
|
170
179
|
- Optionally break lines at sentence boundaries for better diff readability
|
|
171
180
|
|
|
172
|
-
|
|
173
181
|
- Process plaintext with HTML-aware word splitting
|
|
174
182
|
|
|
175
183
|
It is both a library and a command-line tool.
|
|
@@ -195,6 +203,12 @@ Command-line usage examples:
|
|
|
195
203
|
For more details, see: https://github.com/jlevy/flowmark
|
|
196
204
|
```
|
|
197
205
|
|
|
206
|
+
## Project Docs
|
|
207
|
+
|
|
208
|
+
For how to install uv and Python, see [installation.md](installation.md).
|
|
209
|
+
|
|
210
|
+
For development workflows, see [development.md](development.md).
|
|
211
|
+
|
|
198
212
|
For instructions on publishing to PyPI, see [publishing.md](publishing.md).
|
|
199
213
|
|
|
200
214
|
* * *
|
|
@@ -154,7 +154,6 @@ Markdown content. It can:
|
|
|
154
154
|
|
|
155
155
|
- Optionally break lines at sentence boundaries for better diff readability
|
|
156
156
|
|
|
157
|
-
|
|
158
157
|
- Process plaintext with HTML-aware word splitting
|
|
159
158
|
|
|
160
159
|
It is both a library and a command-line tool.
|
|
@@ -180,6 +179,12 @@ Command-line usage examples:
|
|
|
180
179
|
For more details, see: https://github.com/jlevy/flowmark
|
|
181
180
|
```
|
|
182
181
|
|
|
182
|
+
## Project Docs
|
|
183
|
+
|
|
184
|
+
For how to install uv and Python, see [installation.md](installation.md).
|
|
185
|
+
|
|
186
|
+
For development workflows, see [development.md](development.md).
|
|
187
|
+
|
|
183
188
|
For instructions on publishing to PyPI, see [publishing.md](publishing.md).
|
|
184
189
|
|
|
185
190
|
* * *
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import subprocess
|
|
2
2
|
|
|
3
3
|
from funlog import log_calls
|
|
4
|
+
from rich import get_console, reconfigure
|
|
4
5
|
from rich import print as rprint
|
|
5
6
|
|
|
6
7
|
# Update as needed.
|
|
@@ -8,6 +9,9 @@ SRC_PATHS = ["src", "tests", "devtools"]
|
|
|
8
9
|
DOC_PATHS = ["README.md"]
|
|
9
10
|
|
|
10
11
|
|
|
12
|
+
reconfigure(emoji=not get_console().options.legacy_windows) # No emojis on legacy windows.
|
|
13
|
+
|
|
14
|
+
|
|
11
15
|
def main():
|
|
12
16
|
rprint()
|
|
13
17
|
|
|
@@ -20,9 +24,9 @@ def main():
|
|
|
20
24
|
rprint()
|
|
21
25
|
|
|
22
26
|
if errcount != 0:
|
|
23
|
-
rprint(f"[bold red]
|
|
27
|
+
rprint(f"[bold red]:x: Lint failed with {errcount} errors.[/bold red]")
|
|
24
28
|
else:
|
|
25
|
-
rprint("[bold green]
|
|
29
|
+
rprint("[bold green]:white_check_mark: Lint passed![/bold green]")
|
|
26
30
|
rprint()
|
|
27
31
|
|
|
28
32
|
return errcount
|
|
@@ -31,7 +35,7 @@ def main():
|
|
|
31
35
|
@log_calls(level="warning", show_timing_only=True)
|
|
32
36
|
def run(cmd: list[str]) -> int:
|
|
33
37
|
rprint()
|
|
34
|
-
rprint(f"[bold green]
|
|
38
|
+
rprint(f"[bold green]:arrow_forward: {' '.join(cmd)}[/bold green]")
|
|
35
39
|
errcount = 0
|
|
36
40
|
try:
|
|
37
41
|
subprocess.run(cmd, text=True, check=True)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
## Installing uv and Python
|
|
2
|
+
|
|
3
|
+
This project is set up to use [**uv**](https://docs.astral.sh/uv/), the new package
|
|
4
|
+
manager for Python. `uv` replaces traditional use of `pyenv`, `pipx`, `poetry`, `pip`,
|
|
5
|
+
etc. This is a quick cheat sheet on that:
|
|
6
|
+
|
|
7
|
+
On macOS or Linux, if you don't have `uv` installed, a quick way to install it:
|
|
8
|
+
|
|
9
|
+
```shell
|
|
10
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For macOS, you prefer [brew](https://brew.sh/) you can install or upgrade uv with:
|
|
14
|
+
|
|
15
|
+
```shell
|
|
16
|
+
brew update
|
|
17
|
+
brew install uv
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
See [uv's docs](https://docs.astral.sh/uv/getting-started/installation/) for more
|
|
21
|
+
installation methods and platforms.
|
|
22
|
+
|
|
23
|
+
Now you can use uv to install a current Python environment:
|
|
24
|
+
|
|
25
|
+
```shell
|
|
26
|
+
uv python install 3.13 # Or pick another version.
|
|
27
|
+
```
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[project.urls]
|
|
4
4
|
Repository = "https://github.com/jlevy/flowmark"
|
|
5
5
|
# Homepage = "https://..."
|
|
6
|
+
# Documentation = "https://..."
|
|
6
7
|
|
|
7
8
|
[project]
|
|
8
9
|
name = "flowmark"
|
|
@@ -15,6 +16,24 @@ license = "MIT"
|
|
|
15
16
|
requires-python = ">=3.10,<4.0"
|
|
16
17
|
dynamic = ["version"]
|
|
17
18
|
|
|
19
|
+
# https://pypi.org/classifiers/
|
|
20
|
+
# Adjust as needed:
|
|
21
|
+
classifiers = [
|
|
22
|
+
# Adjust as needed:
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
# "Development Status :: 5 - Production/Stable",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Programming Language :: Python",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Typing :: Typed",
|
|
33
|
+
# Include this to avoid accidentally publishing to PyPI:
|
|
34
|
+
# "Private :: Do Not Upload",
|
|
35
|
+
]
|
|
36
|
+
|
|
18
37
|
dependencies = [
|
|
19
38
|
"marko>=2.1.2",
|
|
20
39
|
"regex>=2024.11.6",
|
|
@@ -22,6 +41,7 @@ dependencies = [
|
|
|
22
41
|
"typing-extensions>=4.12.2",
|
|
23
42
|
]
|
|
24
43
|
|
|
44
|
+
|
|
25
45
|
[dependency-groups]
|
|
26
46
|
dev = [
|
|
27
47
|
"pytest>=8.3.5",
|
|
@@ -38,6 +58,7 @@ dev = [
|
|
|
38
58
|
flowmark = "flowmark.cli:main"
|
|
39
59
|
|
|
40
60
|
|
|
61
|
+
|
|
41
62
|
# ---- Build system ----
|
|
42
63
|
|
|
43
64
|
# Dynamic versioning from:
|
|
@@ -58,6 +79,11 @@ vcs = "git"
|
|
|
58
79
|
style = "pep440"
|
|
59
80
|
bump = "true"
|
|
60
81
|
|
|
82
|
+
[tool.hatch.build.targets.wheel]
|
|
83
|
+
# The source location for the package.
|
|
84
|
+
packages = ["src/flowmark"]
|
|
85
|
+
|
|
86
|
+
|
|
61
87
|
# ---- Settings ----
|
|
62
88
|
|
|
63
89
|
[tool.ruff]
|
|
@@ -65,8 +91,8 @@ bump = "true"
|
|
|
65
91
|
line-length = 100
|
|
66
92
|
|
|
67
93
|
[tool.ruff.lint]
|
|
68
|
-
# See: https://docs.astral.sh/ruff/rules/
|
|
69
94
|
select = [
|
|
95
|
+
# See: https://docs.astral.sh/ruff/rules/
|
|
70
96
|
# Basic list from: https://docs.astral.sh/ruff/linter/#rule-selection
|
|
71
97
|
"E", # https://docs.astral.sh/ruff/rules/#error-e
|
|
72
98
|
"F", # https://docs.astral.sh/ruff/rules/#pyflakes-f
|
|
@@ -81,6 +107,7 @@ select = [
|
|
|
81
107
|
|
|
82
108
|
]
|
|
83
109
|
ignore = [
|
|
110
|
+
# Disable some rules that are overly pedantic. Add/remove as desired:
|
|
84
111
|
"E501", # https://docs.astral.sh/ruff/rules/line-too-long/
|
|
85
112
|
"E402", # https://docs.astral.sh/ruff/rules/module-import-not-at-top-of-file/
|
|
86
113
|
"E731", # https://docs.astral.sh/ruff/rules/lambda-assignment/
|
|
@@ -101,28 +128,38 @@ ignore = [
|
|
|
101
128
|
"ISC002", # https://docs.astral.sh/ruff/rules/multi-line-implicit-string-concatenation/
|
|
102
129
|
]
|
|
103
130
|
|
|
131
|
+
[tool.basedpyright]
|
|
104
132
|
# BasedPyright currently seems like the best type checker option, much faster
|
|
105
133
|
# than mypy and with a good extension for VSCode/Cursor.
|
|
106
134
|
# https://marketplace.visualstudio.com/items?itemName=detachhead.basedpyright
|
|
107
135
|
# https://docs.basedpyright.com/latest/configuration/config-files/#sample-pyprojecttoml-file
|
|
108
|
-
[tool.basedpyright]
|
|
109
136
|
include = ["src", "tests", "devtools"]
|
|
110
|
-
|
|
137
|
+
|
|
138
|
+
# By default BasedPyright is very strict, so you almost certainly want to disable
|
|
139
|
+
# some of the rules.
|
|
140
|
+
# First, these turn off warnings about (yes) how you ignore warnings:
|
|
111
141
|
reportIgnoreCommentWithoutRule = false
|
|
112
142
|
reportUnnecessaryTypeIgnoreComment = false
|
|
113
|
-
#
|
|
143
|
+
# A few typically noisy warnings are next.
|
|
144
|
+
# How many you enable is up to you. The first few are off by default, but you can
|
|
145
|
+
# comment/uncomment these as desired:
|
|
114
146
|
reportMissingTypeStubs = false
|
|
115
147
|
reportUnusedCallResult = false
|
|
148
|
+
reportAny = false
|
|
149
|
+
reportExplicitAny = false
|
|
150
|
+
reportImplicitStringConcatenation = false
|
|
151
|
+
# reportUnreachable = false
|
|
152
|
+
# reportUnusedParameter = false
|
|
153
|
+
# reportPrivateImportUsage = false
|
|
154
|
+
# reportPrivateLocalImportUsage = false
|
|
116
155
|
# reportMissingImports = false
|
|
117
156
|
# reportUnnecessaryIsInstance = false
|
|
118
157
|
# reportUnreachable = false
|
|
119
|
-
reportAny = false
|
|
120
|
-
reportExplicitAny = false
|
|
121
|
-
reportUnusedParameter = false
|
|
122
158
|
# reportUnknownVariableType = false
|
|
123
159
|
# reportUnknownArgumentType = false
|
|
124
160
|
|
|
125
161
|
[tool.codespell]
|
|
162
|
+
# Add here as needed:
|
|
126
163
|
# ignore-words-list = "foo,bar"
|
|
127
164
|
skip = "tests/testdocs/*.md"
|
|
128
165
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from marko import block, inline
|
|
6
|
+
from marko.block import Document
|
|
7
|
+
from marko.element import Element
|
|
8
|
+
|
|
9
|
+
ContainerElement = (
|
|
10
|
+
block.Document,
|
|
11
|
+
block.Quote,
|
|
12
|
+
block.List,
|
|
13
|
+
block.ListItem,
|
|
14
|
+
block.Paragraph, # Paragraphs contain inline elements
|
|
15
|
+
block.Heading, # Already handled, but include for completeness if structure changes
|
|
16
|
+
inline.Emphasis,
|
|
17
|
+
inline.StrongEmphasis,
|
|
18
|
+
inline.Link,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def transform_tree(element: Element, transformer: Callable[[Element], None]) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Recursively traverse the element tree and apply a transformer function to each node.
|
|
25
|
+
"""
|
|
26
|
+
transformer(element)
|
|
27
|
+
|
|
28
|
+
# Recursively process children for known container types
|
|
29
|
+
if isinstance(element, ContainerElement):
|
|
30
|
+
# Now we know element has a .children attribute that's a Sequence[Element] or str
|
|
31
|
+
# We only care about processing Element children
|
|
32
|
+
if isinstance(element.children, list):
|
|
33
|
+
# Create a copy for safe iteration if modification occurs
|
|
34
|
+
current_children = list(element.children)
|
|
35
|
+
for child in current_children:
|
|
36
|
+
transform_tree(child, transformer)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _unbold_heading_transformer(element: Element) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Transformer function to unbold headings where the entire text is bold.
|
|
42
|
+
"""
|
|
43
|
+
if isinstance(element, block.Heading):
|
|
44
|
+
# Check if the heading consists *only* of a single StrongEmphasis element
|
|
45
|
+
if len(element.children) == 1 and isinstance(element.children[0], inline.StrongEmphasis):
|
|
46
|
+
# Replace the heading's children with the children of the StrongEmphasis element
|
|
47
|
+
strong_emphasis_node = element.children[0]
|
|
48
|
+
# Type checker struggles here, but StrongEmphasis children should be Elements.
|
|
49
|
+
element.children = strong_emphasis_node.children # pyright: ignore
|
|
50
|
+
|
|
51
|
+
# Handle the case where the heading is bold and italic (StrongEmphasis inside Emphasis or vice versa)
|
|
52
|
+
# ***text*** -> *text*
|
|
53
|
+
elif len(element.children) == 1 and isinstance(element.children[0], inline.Emphasis):
|
|
54
|
+
emphasis_node = element.children[0]
|
|
55
|
+
if len(emphasis_node.children) == 1 and isinstance(
|
|
56
|
+
emphasis_node.children[0], inline.StrongEmphasis
|
|
57
|
+
):
|
|
58
|
+
strong_node = emphasis_node.children[0]
|
|
59
|
+
emphasis_node.children = strong_node.children
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def unbold_headings(doc: Document) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Find headings where the entire text is bold and remove the bold.
|
|
65
|
+
|
|
66
|
+
Modifies the Marko document tree in place using a general transformer.
|
|
67
|
+
Example: `## **My Heading**` -> `## My Heading`
|
|
68
|
+
"""
|
|
69
|
+
transform_tree(doc, _unbold_heading_transformer)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def doc_cleanups(doc: Document):
|
|
73
|
+
"""
|
|
74
|
+
Apply (ideally quite safe) cleanups to the document.
|
|
75
|
+
"""
|
|
76
|
+
unbold_headings(doc)
|
|
@@ -55,6 +55,7 @@ class Options:
|
|
|
55
55
|
width: int
|
|
56
56
|
plaintext: bool
|
|
57
57
|
semantic: bool
|
|
58
|
+
cleanups: bool
|
|
58
59
|
inplace: bool
|
|
59
60
|
nobackup: bool
|
|
60
61
|
version: bool
|
|
@@ -98,6 +99,13 @@ def _parse_args(args: list[str] | None = None) -> Options:
|
|
|
98
99
|
default=False,
|
|
99
100
|
help="Enable semantic (sentence-based) line breaks (only applies to Markdown mode)",
|
|
100
101
|
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"-c",
|
|
104
|
+
"--cleanups",
|
|
105
|
+
action="store_true",
|
|
106
|
+
default=False,
|
|
107
|
+
help="Enable (safe) cleanups for common issues (only applies to Markdown mode)",
|
|
108
|
+
)
|
|
101
109
|
parser.add_argument(
|
|
102
110
|
"-i", "--inplace", action="store_true", help="Edit the file in place (ignores --output)"
|
|
103
111
|
)
|
|
@@ -129,6 +137,7 @@ def _parse_args(args: list[str] | None = None) -> Options:
|
|
|
129
137
|
width=opts.width,
|
|
130
138
|
plaintext=opts.plaintext,
|
|
131
139
|
semantic=opts.semantic,
|
|
140
|
+
cleanups=opts.cleanups,
|
|
132
141
|
inplace=opts.inplace,
|
|
133
142
|
nobackup=opts.nobackup,
|
|
134
143
|
version=opts.version,
|
|
@@ -177,6 +186,7 @@ def main(args: list[str] | None = None) -> int:
|
|
|
177
186
|
text,
|
|
178
187
|
width=options.width,
|
|
179
188
|
semantic=options.semantic,
|
|
189
|
+
cleanups=options.cleanups,
|
|
180
190
|
dedent_input=True,
|
|
181
191
|
)
|
|
182
192
|
|
flowmark-0.3.3/src/flowmark/markdown_filling.py → flowmark-0.4.1/src/flowmark/custom_marko.py
RENAMED
|
@@ -1,78 +1,19 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Auto-formatting of Markdown text.
|
|
3
|
-
|
|
4
|
-
This is similar to what is offered by
|
|
5
|
-
[markdownfmt](https://github.com/shurcooL/markdownfmt) but with a few adaptations,
|
|
6
|
-
including more aggressive normalization and support for wrapping of lines
|
|
7
|
-
semi-semantically (e.g. on sentence boundaries when appropriate).
|
|
8
|
-
(See [here](https://github.com/shurcooL/markdownfmt/issues/17) for some old
|
|
9
|
-
discussion on why line wrapping this way is convenient.)
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
1
|
from __future__ import annotations
|
|
13
2
|
|
|
14
3
|
import re
|
|
15
|
-
from collections.abc import
|
|
4
|
+
from collections.abc import Generator
|
|
16
5
|
from contextlib import contextmanager
|
|
17
|
-
from textwrap import dedent
|
|
18
6
|
from typing import Any, cast
|
|
19
7
|
|
|
20
|
-
from marko import block, inline
|
|
8
|
+
from marko import Markdown, Renderer, block, inline
|
|
21
9
|
from marko.block import HTMLBlock
|
|
10
|
+
from marko.ext.gfm import GFM
|
|
11
|
+
from marko.ext.gfm import elements as gfm_elements
|
|
22
12
|
from marko.parser import Parser
|
|
23
|
-
from marko.renderer import Renderer
|
|
24
13
|
from marko.source import Source
|
|
25
14
|
from typing_extensions import override
|
|
26
15
|
|
|
27
|
-
from flowmark.
|
|
28
|
-
from flowmark.line_wrappers import LineWrapper, line_wrap_by_sentence, line_wrap_to_width
|
|
29
|
-
from flowmark.sentence_split_regex import split_sentences_regex
|
|
30
|
-
from flowmark.text_filling import DEFAULT_WRAP_WIDTH
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _normalize_html_comments(text: str, break_str: str = "\n\n") -> str:
|
|
34
|
-
"""
|
|
35
|
-
Put HTML comments as standalone paragraphs.
|
|
36
|
-
"""
|
|
37
|
-
|
|
38
|
-
# Small hack to avoid changing frontmatter format, for the rare corner
|
|
39
|
-
# case where Markdown contains HTML-style frontmatter.
|
|
40
|
-
def not_frontmatter(text: str) -> bool:
|
|
41
|
-
return "<!---" not in text
|
|
42
|
-
|
|
43
|
-
# TODO: Probably want do this for <div>s too.
|
|
44
|
-
return _ensure_surrounding_breaks(
|
|
45
|
-
text, [("<!--", "-->")], break_str=break_str, filter=not_frontmatter
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _ensure_surrounding_breaks(
|
|
50
|
-
html: str,
|
|
51
|
-
tag_pairs: list[tuple[str, str]],
|
|
52
|
-
filter: Callable[[str], bool] = lambda _: True,
|
|
53
|
-
break_str: str = "\n\n",
|
|
54
|
-
) -> str:
|
|
55
|
-
html_len = len(html)
|
|
56
|
-
for start_tag, end_tag in tag_pairs:
|
|
57
|
-
pattern = re.compile(rf"(\s*{re.escape(start_tag)}.*?{re.escape(end_tag)}\s*)", re.DOTALL)
|
|
58
|
-
|
|
59
|
-
def replacer(match: re.Match[str]) -> str:
|
|
60
|
-
if not filter(match.group(0)):
|
|
61
|
-
return match.group(0)
|
|
62
|
-
|
|
63
|
-
content = match.group(1).strip()
|
|
64
|
-
before = after = break_str
|
|
65
|
-
|
|
66
|
-
if match.start() == 0:
|
|
67
|
-
before = ""
|
|
68
|
-
if match.end() == html_len:
|
|
69
|
-
after = ""
|
|
70
|
-
|
|
71
|
-
return f"{before}{content}{after}"
|
|
72
|
-
|
|
73
|
-
html = re.sub(pattern, replacer, html)
|
|
74
|
-
|
|
75
|
-
return html
|
|
16
|
+
from flowmark.line_wrappers import LineWrapper
|
|
76
17
|
|
|
77
18
|
|
|
78
19
|
# XXX Turn off Marko's parsing of block HTML.
|
|
@@ -94,11 +35,16 @@ class CustomParser(Parser):
|
|
|
94
35
|
self.block_elements["HTMLBlock"] = CustomHTMLBlock
|
|
95
36
|
|
|
96
37
|
|
|
97
|
-
class
|
|
38
|
+
class MarkdownNormalizer(Renderer):
|
|
98
39
|
"""
|
|
99
|
-
Render Markdown in normalized form. This is the internal implementation
|
|
40
|
+
Render Markdown in normalized form. This is the internal implementation
|
|
41
|
+
which overrides most of `MarkdownRenderer`.
|
|
42
|
+
|
|
100
43
|
You likely want to use `normalize_markdown()` instead.
|
|
101
|
-
|
|
44
|
+
|
|
45
|
+
Based on:
|
|
46
|
+
https://github.com/frostming/marko/blob/master/marko/md_renderer.py
|
|
47
|
+
https://github.com/frostming/marko/blob/master/marko/ext/gfm/renderer.py
|
|
102
48
|
"""
|
|
103
49
|
|
|
104
50
|
def __init__(self, line_wrapper: LineWrapper) -> None:
|
|
@@ -109,7 +55,7 @@ class _MarkdownNormalizer(Renderer):
|
|
|
109
55
|
self._line_wrapper: LineWrapper = line_wrapper
|
|
110
56
|
|
|
111
57
|
@override
|
|
112
|
-
def __enter__(self) ->
|
|
58
|
+
def __enter__(self) -> MarkdownNormalizer:
|
|
113
59
|
self._prefix = ""
|
|
114
60
|
self._second_prefix = ""
|
|
115
61
|
return super().__enter__()
|
|
@@ -126,7 +72,14 @@ class _MarkdownNormalizer(Renderer):
|
|
|
126
72
|
# Suppress item breaks on list items following a top-level paragraph.
|
|
127
73
|
if not self._prefix:
|
|
128
74
|
self._suppress_item_break = True
|
|
75
|
+
|
|
129
76
|
children: Any = self.render_children(element)
|
|
77
|
+
|
|
78
|
+
# GFM checkbox support.
|
|
79
|
+
if hasattr(element, "checked"):
|
|
80
|
+
children = f"[{'x' if element.checked else ' '}] {children}" # pyright: ignore
|
|
81
|
+
|
|
82
|
+
# Wrap the text.
|
|
130
83
|
wrapped_text = self._line_wrapper(
|
|
131
84
|
children,
|
|
132
85
|
self._prefix,
|
|
@@ -211,7 +164,7 @@ class _MarkdownNormalizer(Renderer):
|
|
|
211
164
|
self._prefix = self._second_prefix
|
|
212
165
|
return result
|
|
213
166
|
|
|
214
|
-
def render_thematic_break(self,
|
|
167
|
+
def render_thematic_break(self, _element: block.ThematicBreak) -> str:
|
|
215
168
|
result = f"{self._prefix}* * *\n"
|
|
216
169
|
self._prefix = self._second_prefix
|
|
217
170
|
return result
|
|
@@ -224,7 +177,7 @@ class _MarkdownNormalizer(Renderer):
|
|
|
224
177
|
def render_setext_heading(self, element: block.SetextHeading) -> str:
|
|
225
178
|
return self.render_heading(cast(block.Heading, element)) # pyright: ignore
|
|
226
179
|
|
|
227
|
-
def render_blank_line(self,
|
|
180
|
+
def render_blank_line(self, _element: block.BlankLine) -> str:
|
|
228
181
|
if self._prefix.strip():
|
|
229
182
|
result = f"{self._prefix}\n"
|
|
230
183
|
else:
|
|
@@ -288,63 +241,62 @@ class _MarkdownNormalizer(Renderer):
|
|
|
288
241
|
return f"`` {text} ``"
|
|
289
242
|
return f"`{element.children}`"
|
|
290
243
|
|
|
244
|
+
# --- GFM Renderer Methods ---
|
|
291
245
|
|
|
292
|
-
def
|
|
293
|
-
|
|
246
|
+
def render_strikethrough(self, element: gfm_elements.Strikethrough) -> str:
|
|
247
|
+
return f"~~{self.render_children(element)}~~"
|
|
294
248
|
|
|
249
|
+
def render_table(self, element: gfm_elements.Table) -> str:
|
|
250
|
+
"""Render a GFM table."""
|
|
251
|
+
lines: list[str] = []
|
|
252
|
+
head, *body = element.children
|
|
253
|
+
lines.append(self.render(head))
|
|
254
|
+
lines.append(f"| {' | '.join(element.delimiters)} |\n")
|
|
255
|
+
for row in body:
|
|
256
|
+
lines.append(self.render(row))
|
|
257
|
+
return "".join(lines)
|
|
295
258
|
|
|
296
|
-
def
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
width: int = DEFAULT_WRAP_WIDTH,
|
|
300
|
-
semantic: bool = False,
|
|
301
|
-
line_wrapper: LineWrapper | None = None,
|
|
302
|
-
) -> str:
|
|
303
|
-
"""
|
|
304
|
-
Normalize and wrap Markdown text filling paragraphs to the full width.
|
|
305
|
-
|
|
306
|
-
Wraps lines and adds line breaks within paragraphs and on
|
|
307
|
-
best-guess estimations of sentences, to make diffs more readable.
|
|
259
|
+
def render_table_row(self, element: gfm_elements.TableRow) -> str:
|
|
260
|
+
"""Render a row within a GFM table."""
|
|
261
|
+
return f"| {' | '.join(self.render(cell) for cell in element.children)} |\n"
|
|
308
262
|
|
|
309
|
-
|
|
310
|
-
|
|
263
|
+
def render_table_cell(self, element: gfm_elements.TableCell) -> str:
|
|
264
|
+
"""Render a cell within a GFM table row."""
|
|
265
|
+
return self.render_children(element).replace("|", "\\|")
|
|
311
266
|
|
|
312
|
-
|
|
313
|
-
|
|
267
|
+
def render_url(self, element: gfm_elements.Url) -> str:
|
|
268
|
+
"""For GFM autolink URLs, just output the URL directly."""
|
|
269
|
+
return element.dest
|
|
314
270
|
|
|
315
|
-
With `semantic` enabled, the line breaks are wrapped approximately
|
|
316
|
-
by sentence boundaries, to make diffs more readable.
|
|
317
271
|
|
|
318
|
-
|
|
319
|
-
|
|
272
|
+
def custom_marko(line_wrapper: LineWrapper) -> Markdown:
|
|
273
|
+
"""
|
|
274
|
+
Marko Markdown setup for GFM with minor customizations and a
|
|
275
|
+
renderer that normalizes Markdown.
|
|
320
276
|
"""
|
|
321
|
-
if line_wrapper is None:
|
|
322
|
-
line_wrapper = (
|
|
323
|
-
line_wrap_by_sentence(width=width) if semantic else line_wrap_to_width(width=width)
|
|
324
|
-
)
|
|
325
|
-
|
|
326
|
-
# Extract frontmatter before any processing
|
|
327
|
-
frontmatter, content = split_frontmatter(markdown_text)
|
|
328
|
-
|
|
329
|
-
# Only format the content part if there's frontmatter
|
|
330
|
-
if frontmatter:
|
|
331
|
-
markdown_text = content
|
|
332
277
|
|
|
333
|
-
|
|
334
|
-
|
|
278
|
+
class CustomRenderer(MarkdownNormalizer):
|
|
279
|
+
def __init__(self) -> None:
|
|
280
|
+
super().__init__(line_wrapper)
|
|
335
281
|
|
|
336
|
-
|
|
282
|
+
class CustomMarkdown(Markdown):
|
|
283
|
+
def __init__(self) -> None: # pyright: ignore[reportMissingSuperCall]
|
|
284
|
+
pass
|
|
337
285
|
|
|
338
|
-
|
|
339
|
-
|
|
286
|
+
@override
|
|
287
|
+
def _setup_extensions(self) -> None:
|
|
288
|
+
# Using Marko's full extension system is tricky with our customizations so simpler
|
|
289
|
+
# to do this manually.
|
|
290
|
+
custom_parser = CustomParser()
|
|
291
|
+
for e in GFM.elements:
|
|
292
|
+
assert (
|
|
293
|
+
e not in custom_parser.block_elements and e not in custom_parser.inline_elements
|
|
294
|
+
)
|
|
295
|
+
custom_parser.add_element(e)
|
|
340
296
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
parsed = parser.parse(markdown_text)
|
|
344
|
-
result = _MarkdownNormalizer(line_wrapper).render(parsed)
|
|
297
|
+
self.parser: Parser = custom_parser
|
|
298
|
+
self.renderer: Renderer = CustomRenderer()
|
|
345
299
|
|
|
346
|
-
|
|
347
|
-
if frontmatter:
|
|
348
|
-
result = frontmatter + result
|
|
300
|
+
self._setup_done: bool = True
|
|
349
301
|
|
|
350
|
-
return
|
|
302
|
+
return CustomMarkdown()
|