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.
Files changed (37) hide show
  1. {flowmark-0.3.3 → flowmark-0.4.1}/.copier-answers.yml +1 -1
  2. {flowmark-0.3.3 → flowmark-0.4.1}/.github/workflows/ci.yml +2 -1
  3. {flowmark-0.3.3 → flowmark-0.4.1}/.github/workflows/publish.yml +1 -1
  4. {flowmark-0.3.3 → flowmark-0.4.1}/Makefile +2 -1
  5. {flowmark-0.3.3 → flowmark-0.4.1}/PKG-INFO +16 -2
  6. {flowmark-0.3.3 → flowmark-0.4.1}/README.md +6 -1
  7. {flowmark-0.3.3 → flowmark-0.4.1}/devtools/lint.py +7 -3
  8. flowmark-0.4.1/installation.md +27 -0
  9. {flowmark-0.3.3 → flowmark-0.4.1}/pyproject.toml +44 -7
  10. flowmark-0.4.1/src/flowmark/cleanups.py +76 -0
  11. {flowmark-0.3.3 → flowmark-0.4.1}/src/flowmark/cli.py +10 -0
  12. flowmark-0.3.3/src/flowmark/markdown_filling.py → flowmark-0.4.1/src/flowmark/custom_marko.py +68 -116
  13. flowmark-0.4.1/src/flowmark/markdown_filling.py +132 -0
  14. flowmark-0.4.1/src/flowmark/py.typed +0 -0
  15. flowmark-0.4.1/tests/test_cleanups.py +62 -0
  16. flowmark-0.4.1/tests/test_ref_docs.py +48 -0
  17. {flowmark-0.3.3 → flowmark-0.4.1}/tests/testdocs/testdoc.orig.md +33 -4
  18. flowmark-0.3.3/tests/testdocs/testdoc.out.semantic.md → flowmark-0.4.1/tests/testdocs/testdoc.out.cleaned.md +36 -10
  19. {flowmark-0.3.3 → flowmark-0.4.1}/tests/testdocs/testdoc.out.plain.md +36 -10
  20. flowmark-0.4.1/tests/testdocs/testdoc.out.semantic.md +944 -0
  21. flowmark-0.3.3/tests/test_ref_docs.py +0 -47
  22. {flowmark-0.3.3 → flowmark-0.4.1}/.gitignore +0 -0
  23. {flowmark-0.3.3 → flowmark-0.4.1}/LICENSE +0 -0
  24. {flowmark-0.3.3 → flowmark-0.4.1}/development.md +0 -0
  25. {flowmark-0.3.3 → flowmark-0.4.1}/poetry.lock +0 -0
  26. {flowmark-0.3.3 → flowmark-0.4.1}/publishing.md +0 -0
  27. {flowmark-0.3.3 → flowmark-0.4.1}/src/flowmark/__init__.py +0 -0
  28. {flowmark-0.3.3 → flowmark-0.4.1}/src/flowmark/frontmatter.py +0 -0
  29. {flowmark-0.3.3 → flowmark-0.4.1}/src/flowmark/line_wrappers.py +0 -0
  30. {flowmark-0.3.3 → flowmark-0.4.1}/src/flowmark/sentence_split_regex.py +0 -0
  31. {flowmark-0.3.3 → flowmark-0.4.1}/src/flowmark/text_filling.py +0 -0
  32. {flowmark-0.3.3 → flowmark-0.4.1}/src/flowmark/text_wrapping.py +0 -0
  33. {flowmark-0.3.3 → flowmark-0.4.1}/tests/test_filling.py +0 -0
  34. {flowmark-0.3.3 → flowmark-0.4.1}/tests/test_frontmatter.py +0 -0
  35. {flowmark-0.3.3 → flowmark-0.4.1}/tests/test_sentences.py +0 -0
  36. {flowmark-0.3.3 → flowmark-0.4.1}/tests/test_wrapping.py +0 -0
  37. {flowmark-0.3.3 → flowmark-0.4.1}/uv.lock +0 -0
@@ -1,5 +1,5 @@
1
1
  # Changes here will be overwritten by Copier. Do not edit manually.
2
- _commit: v0.2.0
2
+ _commit: v0.2.7
3
3
  _src_path: gh:jlevy/simple-modern-uv
4
4
  package_author_email: joshua@cal.berkeley.edu
5
5
  package_author_name: Joshua Levy
@@ -40,7 +40,8 @@ jobs:
40
40
  - name: Install uv (official Astral action)
41
41
  uses: astral-sh/setup-uv@v5
42
42
  with:
43
- version: "0.6.5"
43
+ # Update this as needed:
44
+ version: "0.6.14"
44
45
  enable-cache: true
45
46
  python-version: ${{ matrix.python-version }}
46
47
 
@@ -21,7 +21,7 @@ jobs:
21
21
  - name: Install uv (official Astral action)
22
22
  uses: astral-sh/setup-uv@v5
23
23
  with:
24
- version: "0.6.5"
24
+ version: "0.6.14"
25
25
  enable-cache: true
26
26
  python-version: "3.12"
27
27
 
@@ -1,5 +1,6 @@
1
1
  # Makefile for easy development workflows.
2
- # GitHub Actions call uv directly.
2
+ # See development.md for docs.
3
+ # Note GitHub Actions call uv directly, not this Makefile.
3
4
 
4
5
  .DEFAULT_GOAL := default
5
6
 
@@ -1,11 +1,20 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowmark
3
- Version: 0.3.3
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]✗ Lint failed with {errcount} errors.[/bold red]")
27
+ rprint(f"[bold red]:x: Lint failed with {errcount} errors.[/bold red]")
24
28
  else:
25
- rprint("[bold green]✔️ Lint passed![/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]❯ {' '.join(cmd)}[/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
- # Make ignoring easier:
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
- # Typically noisy warnings, comment/uncomment as desired:
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
 
@@ -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 Callable, Generator
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.frontmatter import split_frontmatter
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 _MarkdownNormalizer(Renderer):
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
- Based on: https://github.com/frostming/marko/blob/master/marko/md_renderer.py
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) -> _MarkdownNormalizer:
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, element: block.ThematicBreak) -> str:
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, element: block.BlankLine) -> str:
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 split_sentences_no_min_length(text: str) -> list[str]:
293
- return split_sentences_regex(text, min_length=0)
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 fill_markdown(
297
- markdown_text: str,
298
- dedent_input: bool = True,
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
- Also enforces that all list items have two newlines between them, so
310
- that items are separate paragraphs when viewed as plaintext.
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
- Optionally also dedents and strips the input, so it can be used
313
- on docstrings.
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
- Preserves YAML frontmatter (delimited by --- lines) if present at the
319
- beginning of the document.
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
- if dedent_input:
334
- markdown_text = dedent(markdown_text).strip()
278
+ class CustomRenderer(MarkdownNormalizer):
279
+ def __init__(self) -> None:
280
+ super().__init__(line_wrapper)
335
281
 
336
- markdown_text = markdown_text.strip() + "\n"
282
+ class CustomMarkdown(Markdown):
283
+ def __init__(self) -> None: # pyright: ignore[reportMissingSuperCall]
284
+ pass
337
285
 
338
- # If we want to normalize HTML blocks or comments.
339
- markdown_text = _normalize_html_comments(markdown_text)
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
- # Normalize the markdown and wrap lines.
342
- parser = CustomParser()
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
- # Reattach frontmatter if it was present
347
- if frontmatter:
348
- result = frontmatter + result
300
+ self._setup_done: bool = True
349
301
 
350
- return result
302
+ return CustomMarkdown()