flowmark 0.4.7__tar.gz → 0.4.8__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 (46) hide show
  1. {flowmark-0.4.7 → flowmark-0.4.8}/.cursor/rules/flowmark.mdc +5 -4
  2. {flowmark-0.4.7 → flowmark-0.4.8}/Makefile +4 -5
  3. {flowmark-0.4.7 → flowmark-0.4.8}/PKG-INFO +25 -20
  4. {flowmark-0.4.7 → flowmark-0.4.8}/README.md +24 -19
  5. {flowmark-0.4.7 → flowmark-0.4.8}/src/flowmark/cli.py +12 -1
  6. {flowmark-0.4.7 → flowmark-0.4.8}/src/flowmark/custom_marko.py +13 -13
  7. {flowmark-0.4.7 → flowmark-0.4.8}/src/flowmark/markdown_filling.py +5 -1
  8. {flowmark-0.4.7 → flowmark-0.4.8}/src/flowmark/reformat_api.py +8 -2
  9. flowmark-0.4.8/src/flowmark/smartquotes.py +98 -0
  10. flowmark-0.4.8/tests/test_list_spacing.py +312 -0
  11. {flowmark-0.4.7 → flowmark-0.4.8}/tests/test_ref_docs.py +33 -4
  12. flowmark-0.4.8/tests/test_smartquotes.py +129 -0
  13. flowmark-0.4.8/tests/testdocs/testdoc.expected.auto.md +1074 -0
  14. flowmark-0.4.7/tests/testdocs/testdoc.out.cleaned.md → flowmark-0.4.8/tests/testdocs/testdoc.expected.cleaned.md +2 -1
  15. flowmark-0.4.7/tests/testdocs/testdoc.out.plain.md → flowmark-0.4.8/tests/testdocs/testdoc.expected.plain.md +2 -1
  16. flowmark-0.4.7/tests/testdocs/testdoc.out.semantic.md → flowmark-0.4.8/tests/testdocs/testdoc.expected.semantic.md +2 -1
  17. {flowmark-0.4.7 → flowmark-0.4.8}/.copier-answers.yml +0 -0
  18. {flowmark-0.4.7 → flowmark-0.4.8}/.cursor/rules/general.mdc +0 -0
  19. {flowmark-0.4.7 → flowmark-0.4.8}/.cursor/rules/python.mdc +0 -0
  20. {flowmark-0.4.7 → flowmark-0.4.8}/.github/workflows/ci.yml +0 -0
  21. {flowmark-0.4.7 → flowmark-0.4.8}/.github/workflows/publish.yml +0 -0
  22. {flowmark-0.4.7 → flowmark-0.4.8}/.gitignore +0 -0
  23. {flowmark-0.4.7 → flowmark-0.4.8}/.vscode/settings.json +0 -0
  24. {flowmark-0.4.7 → flowmark-0.4.8}/LICENSE +0 -0
  25. {flowmark-0.4.7 → flowmark-0.4.8}/development.md +0 -0
  26. {flowmark-0.4.7 → flowmark-0.4.8}/devtools/lint.py +0 -0
  27. {flowmark-0.4.7 → flowmark-0.4.8}/installation.md +0 -0
  28. {flowmark-0.4.7 → flowmark-0.4.8}/poetry.lock +0 -0
  29. {flowmark-0.4.7 → flowmark-0.4.8}/publishing.md +0 -0
  30. {flowmark-0.4.7 → flowmark-0.4.8}/pyproject.toml +0 -0
  31. {flowmark-0.4.7 → flowmark-0.4.8}/src/flowmark/__init__.py +0 -0
  32. {flowmark-0.4.7 → flowmark-0.4.8}/src/flowmark/cleanups.py +0 -0
  33. {flowmark-0.4.7 → flowmark-0.4.8}/src/flowmark/frontmatter.py +0 -0
  34. {flowmark-0.4.7 → flowmark-0.4.8}/src/flowmark/line_wrappers.py +0 -0
  35. {flowmark-0.4.7 → flowmark-0.4.8}/src/flowmark/py.typed +0 -0
  36. {flowmark-0.4.7 → flowmark-0.4.8}/src/flowmark/sentence_split_regex.py +0 -0
  37. {flowmark-0.4.7 → flowmark-0.4.8}/src/flowmark/text_filling.py +0 -0
  38. {flowmark-0.4.7 → flowmark-0.4.8}/src/flowmark/text_wrapping.py +0 -0
  39. {flowmark-0.4.7 → flowmark-0.4.8}/tests/test_cleanups.py +0 -0
  40. {flowmark-0.4.7 → flowmark-0.4.8}/tests/test_filling.py +0 -0
  41. {flowmark-0.4.7 → flowmark-0.4.8}/tests/test_frontmatter.py +0 -0
  42. {flowmark-0.4.7 → flowmark-0.4.8}/tests/test_sentences.py +0 -0
  43. {flowmark-0.4.7 → flowmark-0.4.8}/tests/test_wrapping.py +0 -0
  44. {flowmark-0.4.7 → flowmark-0.4.8}/tests/testdocs/.gitignore +0 -0
  45. {flowmark-0.4.7 → flowmark-0.4.8}/tests/testdocs/testdoc.orig.md +0 -0
  46. {flowmark-0.4.7 → flowmark-0.4.8}/uv.lock +0 -0
@@ -10,7 +10,7 @@ Most comprehensive testing is done via the test files in the tests/testdocs dire
10
10
 
11
11
  The input is tests/testdocs/testdoc.orig.md.
12
12
 
13
- The expected output is in tests/testdocs/testdoc.out.*.md.
13
+ The expected output is in tests/testdocs/testdoc.expected.*.md.
14
14
 
15
15
  When you run `make test`, the actual output from the code is written to tests/testdocs/testdoc.actual.*.md.
16
16
 
@@ -18,8 +18,9 @@ The test passes if the actual output matches the expected output.
18
18
 
19
19
  If they do NOT match, it is a bug and the test fails.
20
20
  After a failure, the testdoc.actual.*.md files are still updated and can be
21
- compared to the expected testdoc.out.*.md files to understand what went wrong.
21
+ compared to the expected testdoc.expected.*.md files to understand what went wrong.
22
22
 
23
- DO NOT change the testdoc.out.*.md files since they should be the expected output.
23
+ DO NOT change the testdoc.expected.*.md files since they should be the expected output,
24
+ unless the user confirms that the testdoc.expected.*.md files are incorrect.
24
25
 
25
- Change the code to fix bugs until the testdoc.actual.*.md files match the expected testdoc.out.*.md files.
26
+ Change the code to fix bugs until the testdoc.actual.*.md files match the expected testdoc.expected.*.md files.
@@ -4,9 +4,9 @@
4
4
 
5
5
  .DEFAULT_GOAL := default
6
6
 
7
- .PHONY: default install lint test upgrade build clean agent-rules
7
+ .PHONY: default install lint test upgrade build clean agent-rules format-docs
8
8
 
9
- default: agent-rules install lint test
9
+ default: agent-rules format-docs install lint test
10
10
 
11
11
  install:
12
12
  uv sync --all-extras
@@ -42,6 +42,5 @@ clean:
42
42
  -rm -rf CLAUDE.md AGENTS.md
43
43
  -find . -type d -name "__pycache__" -exec rm -rf {} +
44
44
 
45
- gen_test_docs:
46
- poetry run flowmark tests/testdocs/testdoc.orig.md -o tests/testdocs/testdoc.out.plain.md
47
- poetry run flowmark --semantic tests/testdocs/testdoc.orig.md -o tests/testdocs/testdoc.out.semantic.md
45
+ format-docs:
46
+ uv run flowmark --auto README.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowmark
3
- Version: 0.4.7
3
+ Version: 0.4.8
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>
@@ -24,15 +24,19 @@ Description-Content-Type: text/markdown
24
24
 
25
25
  # flowmark
26
26
 
27
- Flowmark is a new Python implementation of **text and Markdown line wrapping and
28
- filling**, with an emphasis on making **git diffs** and **LLM edits** to text documents
29
- easier to diff and review.
27
+ Flowmark is a pure Python implementation of **Markdown auto-formatting and
28
+ normalization** and **text and Markdown line wrapping and filling**. It can be used from
29
+ the command line or a library.
30
30
 
31
- In addition, it offers **Markdown auto-formatting and normalization** as a library or
32
- from the command line.
33
31
  This is much like [markdownfmt](https://github.com/shurcooL/markdownfmt) or
34
- [prettier's Markdown support](https://prettier.io/blog/2017/11/07/1.8.0) but is pure
35
- Python and has (in my humble opinion) better options and defaults.
32
+ [prettier’s Markdown support](https://prettier.io/blog/2017/11/07/1.8.0) but is pure
33
+ Python and has more options and (in my humble opinion) better defaults.
34
+ In particular, it was written to make **git diffs** and **LLM edits** of Markdown text
35
+ documents much easier to review.
36
+
37
+ It also offers optional **automatic smart quotes** to convert \"non-oriented quotes\" to
38
+ “oriented quotes” and apostrophes intelligently and conservatively (in particular,
39
+ avoiding code blocks).
36
40
 
37
41
  It aims to be small and simple and have only a few dependencies, currently only
38
42
  [`marko`](https://github.com/frostming/marko),
@@ -73,7 +77,7 @@ To use as a library, use uv/poetry/pip to install
73
77
  ## Use in VSCode/Cursor
74
78
 
75
79
  You can use Flowmark to auto-format Markdown on save in VSCode or Cursor.
76
- Install the "Run on Save" (`emeraldwalk.runonsave`) extension.
80
+ Install the “Run on Save” (`emeraldwalk.runonsave`) extension.
77
81
  Then add to your `settings.json`:
78
82
 
79
83
  ```json
@@ -87,7 +91,8 @@ Then add to your `settings.json`:
87
91
  }
88
92
  ```
89
93
 
90
- The `--auto` option is just the same as `--inplace --nobackup --semantic`.
94
+ The `--auto` option is just the same as `--inplace --nobackup --semantic --cleanups
95
+ --smartquotes`.
91
96
 
92
97
  ## Use Cases
93
98
 
@@ -109,11 +114,11 @@ The main ways to use Flowmark are:
109
114
  Having this as a simple Python library makes this easy in AI-related document
110
115
  pipelines.
111
116
 
112
- - As a **drop-in replacement library for Python's default
117
+ - As a **drop-in replacement library for Python’s default
113
118
  [`textwrap`](https://docs.python.org/3/library/textwrap.html)** but with more options.
114
119
  It simplifies and generalizes that library, offering better control over **initial and
115
120
  subsequent indentation** and **when to split words and lines**, e.g. using a word
116
- splitter that won't break lines within HTML tags.
121
+ splitter that won’t break lines within HTML tags.
117
122
 
118
123
  Other features:
119
124
 
@@ -126,10 +131,10 @@ Other features:
126
131
  [Markdown source](https://github.com/jlevy/flowmark/blob/main/README.md?plain=1) of
127
132
  this readme file.)
128
133
 
129
- - Very simple and fast **regex-based sentence splitting**. It's just based on letters
130
- and punctuation so isn't perfect but works well for these purposes (and is much faster
134
+ - Very simple and fast **regex-based sentence splitting**. It’s just based on letters
135
+ and punctuation so isn’t perfect but works well for these purposes (and is much faster
131
136
  and simpler than a proper sentence parser like SpaCy).
132
- It should work fine for English and many other latin/Cyrillic languages but hasn't
137
+ It should work fine for English and many other latin/Cyrillic languages but hasn’t
133
138
  been tested on CJK.
134
139
 
135
140
  Because **YAML frontmatter** is common on Markdown files, the Markdown autoformat
@@ -137,7 +142,7 @@ preserves all frontmatter (content between `---` delimiters at the front of a fi
137
142
 
138
143
  ## Why a New Markdown Formatter?
139
144
 
140
- Previously I'd implemented something very similar with
145
+ Previously I’d implemented something very similar with
141
146
  [for Atom](https://github.com/jlevy/atom-flowmark).
142
147
  I found the Markdown formatting conventions enforced by the that plugin worked really
143
148
  well for editing and publishing large or collaboratively edited documents.
@@ -178,7 +183,7 @@ options:
178
183
  boldfaced section headers (only applies to Markdown mode)
179
184
  -i, --inplace Edit the file in place (ignores --output)
180
185
  --nobackup Do not make a backup of the original file when using --inplace
181
- --auto Same as `--inplace --nobackup --semantic --cleanups`, as a
186
+ --auto Same as `--inplace --nobackup --semantic --cleanups --smartquotes`, as a
182
187
  convenience for fully auto-formatting files
183
188
  --version Show version information and exit
184
189
 
@@ -221,13 +226,13 @@ For more details, see: https://github.com/jlevy/flowmark
221
226
  - This enables
222
227
  [GitHub-flavored Markdown support](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax)
223
228
  using
224
- [Marko's extension](https://github.com/frostming/marko/blob/master/marko/ext/footnote.py).
229
+ [Marko’s extension](https://github.com/frostming/marko/blob/master/marko/ext/footnote.py).
225
230
 
226
231
  - GFM-style tables are supported and also auto-formatted.
227
232
 
228
233
  - GFM-style footnotes are supported.
229
- But note these aren't actually in the GFM spec, but we follow
230
- [micromark's conventions](https://github.com/frostming/marko/blob/master/marko/ext/footnote.py).
234
+ But note these aren’t actually in the GFM spec, but we follow
235
+ [micromark’s conventions](https://github.com/frostming/marko/blob/master/marko/ext/footnote.py).
231
236
 
232
237
  ## Project Docs
233
238
 
@@ -1,14 +1,18 @@
1
1
  # flowmark
2
2
 
3
- Flowmark is a new Python implementation of **text and Markdown line wrapping and
4
- filling**, with an emphasis on making **git diffs** and **LLM edits** to text documents
5
- easier to diff and review.
3
+ Flowmark is a pure Python implementation of **Markdown auto-formatting and
4
+ normalization** and **text and Markdown line wrapping and filling**. It can be used from
5
+ the command line or a library.
6
6
 
7
- In addition, it offers **Markdown auto-formatting and normalization** as a library or
8
- from the command line.
9
7
  This is much like [markdownfmt](https://github.com/shurcooL/markdownfmt) or
10
- [prettier's Markdown support](https://prettier.io/blog/2017/11/07/1.8.0) but is pure
11
- Python and has (in my humble opinion) better options and defaults.
8
+ [prettier’s Markdown support](https://prettier.io/blog/2017/11/07/1.8.0) but is pure
9
+ Python and has more options and (in my humble opinion) better defaults.
10
+ In particular, it was written to make **git diffs** and **LLM edits** of Markdown text
11
+ documents much easier to review.
12
+
13
+ It also offers optional **automatic smart quotes** to convert \"non-oriented quotes\" to
14
+ “oriented quotes” and apostrophes intelligently and conservatively (in particular,
15
+ avoiding code blocks).
12
16
 
13
17
  It aims to be small and simple and have only a few dependencies, currently only
14
18
  [`marko`](https://github.com/frostming/marko),
@@ -49,7 +53,7 @@ To use as a library, use uv/poetry/pip to install
49
53
  ## Use in VSCode/Cursor
50
54
 
51
55
  You can use Flowmark to auto-format Markdown on save in VSCode or Cursor.
52
- Install the "Run on Save" (`emeraldwalk.runonsave`) extension.
56
+ Install the “Run on Save” (`emeraldwalk.runonsave`) extension.
53
57
  Then add to your `settings.json`:
54
58
 
55
59
  ```json
@@ -63,7 +67,8 @@ Then add to your `settings.json`:
63
67
  }
64
68
  ```
65
69
 
66
- The `--auto` option is just the same as `--inplace --nobackup --semantic`.
70
+ The `--auto` option is just the same as `--inplace --nobackup --semantic --cleanups
71
+ --smartquotes`.
67
72
 
68
73
  ## Use Cases
69
74
 
@@ -85,11 +90,11 @@ The main ways to use Flowmark are:
85
90
  Having this as a simple Python library makes this easy in AI-related document
86
91
  pipelines.
87
92
 
88
- - As a **drop-in replacement library for Python's default
93
+ - As a **drop-in replacement library for Python’s default
89
94
  [`textwrap`](https://docs.python.org/3/library/textwrap.html)** but with more options.
90
95
  It simplifies and generalizes that library, offering better control over **initial and
91
96
  subsequent indentation** and **when to split words and lines**, e.g. using a word
92
- splitter that won't break lines within HTML tags.
97
+ splitter that won’t break lines within HTML tags.
93
98
 
94
99
  Other features:
95
100
 
@@ -102,10 +107,10 @@ Other features:
102
107
  [Markdown source](https://github.com/jlevy/flowmark/blob/main/README.md?plain=1) of
103
108
  this readme file.)
104
109
 
105
- - Very simple and fast **regex-based sentence splitting**. It's just based on letters
106
- and punctuation so isn't perfect but works well for these purposes (and is much faster
110
+ - Very simple and fast **regex-based sentence splitting**. It’s just based on letters
111
+ and punctuation so isn’t perfect but works well for these purposes (and is much faster
107
112
  and simpler than a proper sentence parser like SpaCy).
108
- It should work fine for English and many other latin/Cyrillic languages but hasn't
113
+ It should work fine for English and many other latin/Cyrillic languages but hasn’t
109
114
  been tested on CJK.
110
115
 
111
116
  Because **YAML frontmatter** is common on Markdown files, the Markdown autoformat
@@ -113,7 +118,7 @@ preserves all frontmatter (content between `---` delimiters at the front of a fi
113
118
 
114
119
  ## Why a New Markdown Formatter?
115
120
 
116
- Previously I'd implemented something very similar with
121
+ Previously I’d implemented something very similar with
117
122
  [for Atom](https://github.com/jlevy/atom-flowmark).
118
123
  I found the Markdown formatting conventions enforced by the that plugin worked really
119
124
  well for editing and publishing large or collaboratively edited documents.
@@ -154,7 +159,7 @@ options:
154
159
  boldfaced section headers (only applies to Markdown mode)
155
160
  -i, --inplace Edit the file in place (ignores --output)
156
161
  --nobackup Do not make a backup of the original file when using --inplace
157
- --auto Same as `--inplace --nobackup --semantic --cleanups`, as a
162
+ --auto Same as `--inplace --nobackup --semantic --cleanups --smartquotes`, as a
158
163
  convenience for fully auto-formatting files
159
164
  --version Show version information and exit
160
165
 
@@ -197,13 +202,13 @@ For more details, see: https://github.com/jlevy/flowmark
197
202
  - This enables
198
203
  [GitHub-flavored Markdown support](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax)
199
204
  using
200
- [Marko's extension](https://github.com/frostming/marko/blob/master/marko/ext/footnote.py).
205
+ [Marko’s extension](https://github.com/frostming/marko/blob/master/marko/ext/footnote.py).
201
206
 
202
207
  - GFM-style tables are supported and also auto-formatted.
203
208
 
204
209
  - GFM-style footnotes are supported.
205
- But note these aren't actually in the GFM spec, but we follow
206
- [micromark's conventions](https://github.com/frostming/marko/blob/master/marko/ext/footnote.py).
210
+ But note these aren’t actually in the GFM spec, but we follow
211
+ [micromark’s conventions](https://github.com/frostming/marko/blob/master/marko/ext/footnote.py).
207
212
 
208
213
  ## Project Docs
209
214
 
@@ -54,6 +54,7 @@ class Options:
54
54
  plaintext: bool
55
55
  semantic: bool
56
56
  cleanups: bool
57
+ smartquotes: bool
57
58
  inplace: bool
58
59
  nobackup: bool
59
60
  version: bool
@@ -105,6 +106,13 @@ def _parse_args(args: list[str] | None = None) -> Options:
105
106
  help="Enable (safe) cleanups for common issues like accidentally boldfaced section "
106
107
  "headers (only applies to Markdown mode)",
107
108
  )
109
+ parser.add_argument(
110
+ "--smartquotes",
111
+ action="store_true",
112
+ default=False,
113
+ help="Convert straight quotes to typographic (curly) quotes and apostrophes "
114
+ "(only applies to Markdown mode)",
115
+ )
108
116
  parser.add_argument(
109
117
  "-i", "--inplace", action="store_true", help="Edit the file in place (ignores --output)"
110
118
  )
@@ -116,7 +124,7 @@ def _parse_args(args: list[str] | None = None) -> Options:
116
124
  parser.add_argument(
117
125
  "--auto",
118
126
  action="store_true",
119
- help="Same as `--inplace --nobackup --semantic --cleanups`, as a convenience for "
127
+ help="Same as `--inplace --nobackup --semantic --cleanups --smartquotes`, as a convenience for "
120
128
  "fully auto-formatting files",
121
129
  )
122
130
  parser.add_argument(
@@ -131,6 +139,7 @@ def _parse_args(args: list[str] | None = None) -> Options:
131
139
  opts.nobackup = True
132
140
  opts.semantic = True
133
141
  opts.cleanups = True
142
+ opts.smartquotes = True
134
143
 
135
144
  return Options(
136
145
  file=opts.file,
@@ -139,6 +148,7 @@ def _parse_args(args: list[str] | None = None) -> Options:
139
148
  plaintext=opts.plaintext,
140
149
  semantic=opts.semantic,
141
150
  cleanups=opts.cleanups,
151
+ smartquotes=opts.smartquotes,
142
152
  inplace=opts.inplace,
143
153
  nobackup=opts.nobackup,
144
154
  version=opts.version,
@@ -176,6 +186,7 @@ def main(args: list[str] | None = None) -> int:
176
186
  plaintext=options.plaintext,
177
187
  semantic=options.semantic,
178
188
  cleanups=options.cleanups,
189
+ smartquotes=options.smartquotes,
179
190
  make_parents=True,
180
191
  )
181
192
  except ValueError as e:
@@ -81,6 +81,10 @@ class MarkdownNormalizer(Renderer):
81
81
  # Suppress item breaks on list items following a top-level paragraph.
82
82
  if not self._prefix:
83
83
  self._suppress_item_break = True
84
+ else:
85
+ # For paragraphs within list items, ensure proper spacing after multi-paragraph items
86
+ # This handles the case where a paragraph follows a BlankLine within a list item
87
+ self._suppress_item_break = False
84
88
 
85
89
  children: Any = self.render_children(element)
86
90
 
@@ -97,14 +101,6 @@ class MarkdownNormalizer(Renderer):
97
101
  self._prefix = self._second_prefix
98
102
  return wrapped_text + "\n"
99
103
 
100
- def _has_multiple_paragraphs(self, item: object) -> bool:
101
- """
102
- Check if a list item contains multiple paragraphs.
103
- """
104
- list_item = cast(block.ListItem, item)
105
- paragraphs = [c for c in list_item.children if isinstance(c, block.Paragraph)]
106
- return len(paragraphs) > 1
107
-
108
104
  def render_list(self, element: block.List) -> str:
109
105
  result: list[str] = []
110
106
 
@@ -119,11 +115,8 @@ class MarkdownNormalizer(Renderer):
119
115
  subsequent_indent = " "
120
116
 
121
117
  with self.container(prefix, subsequent_indent):
122
- # Add an extra newline before multi-paragraph list items (except the first)
123
- if i > 0 and self._has_multiple_paragraphs(child):
124
- result.append(self._second_prefix.strip() + "\n")
125
-
126
- result.append(self.render(child))
118
+ rendered_item = self.render(child)
119
+ result.append(rendered_item)
127
120
 
128
121
  self._prefix = self._second_prefix
129
122
  return "".join(result)
@@ -139,12 +132,16 @@ class MarkdownNormalizer(Renderer):
139
132
  result += self._second_prefix.strip() + "\n"
140
133
 
141
134
  result += self.render_children(element)
135
+
142
136
  return result
143
137
 
144
138
  def render_quote(self, element: block.Quote) -> str:
145
139
  with self.container("> ", "> "):
146
140
  result = self.render_children(element).rstrip("\n")
147
141
  self._prefix = self._second_prefix
142
+ # After rendering a quote block, don't suppress the next item break
143
+ # This ensures proper spacing after list items with quote blocks
144
+ self._suppress_item_break = False
148
145
  return f"{result}\n"
149
146
 
150
147
  def _render_code(self, element: block.CodeBlock | block.FencedCode) -> str:
@@ -159,6 +156,9 @@ class MarkdownNormalizer(Renderer):
159
156
  lines.extend(f"{self._second_prefix}{line}" for line in code_content.splitlines())
160
157
  lines.append(f"{self._second_prefix}```")
161
158
  self._prefix = self._second_prefix
159
+ # After rendering a code block, don't suppress the next item break
160
+ # This ensures proper spacing after list items with code blocks
161
+ self._suppress_item_break = False
162
162
  return "\n".join(lines) + "\n"
163
163
 
164
164
  def render_fenced_code(self, element: block.FencedCode) -> str:
@@ -15,11 +15,12 @@ import re
15
15
  from collections.abc import Callable
16
16
  from textwrap import dedent
17
17
 
18
- from flowmark.cleanups import doc_cleanups
18
+ from flowmark.cleanups import doc_cleanups, rewrite_text_content
19
19
  from flowmark.custom_marko import custom_marko
20
20
  from flowmark.frontmatter import split_frontmatter
21
21
  from flowmark.line_wrappers import LineWrapper, line_wrap_by_sentence, line_wrap_to_width
22
22
  from flowmark.sentence_split_regex import split_sentences_regex
23
+ from flowmark.smartquotes import smart_quotes
23
24
  from flowmark.text_filling import DEFAULT_WRAP_WIDTH
24
25
 
25
26
 
@@ -79,6 +80,7 @@ def fill_markdown(
79
80
  width: int = DEFAULT_WRAP_WIDTH,
80
81
  semantic: bool = False,
81
82
  cleanups: bool = False,
83
+ smartquotes: bool = False,
82
84
  line_wrapper: LineWrapper | None = None,
83
85
  ) -> str:
84
86
  """
@@ -125,6 +127,8 @@ def fill_markdown(
125
127
  document = marko.parse(markdown_text)
126
128
  if cleanups:
127
129
  doc_cleanups(document)
130
+ if smartquotes:
131
+ rewrite_text_content(document, smart_quotes)
128
132
  result = marko.render(document)
129
133
 
130
134
  # Reattach frontmatter if it was present
@@ -14,6 +14,7 @@ def reformat_text(
14
14
  plaintext: bool = False,
15
15
  semantic: bool = True,
16
16
  cleanups: bool = True,
17
+ smartquotes: bool = False,
17
18
  ) -> str:
18
19
  """
19
20
  Reformat text or markdown and wrap lines. Simply a convenient wrapper
@@ -29,7 +30,9 @@ def reformat_text(
29
30
  )
30
31
  else:
31
32
  # Markdown mode
32
- result = fill_markdown(text, width=width, semantic=semantic, cleanups=cleanups)
33
+ result = fill_markdown(
34
+ text, width=width, semantic=semantic, cleanups=cleanups, smartquotes=smartquotes
35
+ )
33
36
 
34
37
  return result
35
38
 
@@ -43,6 +46,7 @@ def reformat_file(
43
46
  plaintext: bool = False,
44
47
  semantic: bool = False,
45
48
  cleanups: bool = True,
49
+ smartquotes: bool = False,
46
50
  make_parents: bool = True,
47
51
  ) -> None:
48
52
  """
@@ -60,6 +64,8 @@ def reformat_file(
60
64
  semantic: Use semantic line breaks (based on sentences) heuristic.
61
65
  cleanups: Enable (safe) cleanups for common issues like accidentally boldfaced section
62
66
  headers (only applies to Markdown mode).
67
+ smartquotes: Convert straight quotes to typographic (curly) quotes and apostrophes
68
+ (only applies to Markdown mode).
63
69
  make_parents: Whether to make parent directories if they don't exist.
64
70
  """
65
71
  read_stdin = path == "-"
@@ -73,7 +79,7 @@ def reformat_file(
73
79
  else:
74
80
  text = Path(path).read_text()
75
81
 
76
- result = reformat_text(text, width, plaintext, semantic, cleanups)
82
+ result = reformat_text(text, width, plaintext, semantic, cleanups, smartquotes)
77
83
 
78
84
  if inplace:
79
85
  backup_suffix = ".orig" if not nobackup else ""
@@ -0,0 +1,98 @@
1
+ import re
2
+ from re import Match
3
+
4
+
5
+ def smart_quotes(text: str) -> str:
6
+ r"""
7
+ Replace straight ASCII quotes and apostrophes with typographic quotes and apostrophes
8
+ when this can be done safely. Aims to be conservative so it doesn't break code or
9
+ things that aren't language.
10
+
11
+ Text that is wrapped in single or double quotes is replaced with typographic quotes
12
+ if it has whitespace or a newline at the front and is followed by whitespace or
13
+ a [.,?!]. The content inside quotes must not contain any of the same type (single
14
+ or double).
15
+
16
+ Straight quotes are converted to apostrophes if they are the only straight quote
17
+ in the word, and have word characters on both sides:
18
+
19
+ I'm there with "George" -> I’m there with “George”
20
+ "Hello," he said. -> “Hello,” he said.
21
+ "I know!" -> “I know!”
22
+
23
+ Words in 'single quotes' work too -> Words in 'single quotes' work too
24
+
25
+ I'm there -> I’m there
26
+ I'll be there, don't worry -> I’ll be there, don’t worry
27
+ X is 'foo' -> X is ‘foo’
28
+
29
+ A few special rules to better help with English:
30
+
31
+ Jill's -> Jill’s
32
+ James' -> James’
33
+
34
+ Other patterns are unchanged:
35
+
36
+ x="foo" -> x="foo"
37
+ x='foo' -> x='foo'
38
+ Blah'blah'blah -> Blah'blah'blah
39
+ ""quotes"s -> ""quotes"s
40
+ \"escaped\" -> \"escaped\"
41
+ 'apos -> 'apos
42
+ 'apos'trophes -> 'apos'trophes
43
+ $James' -> $James'
44
+
45
+ """
46
+
47
+ # First handle quoted text - both single and double quotes
48
+ # Pattern excludes content that contains the same type of quote characters
49
+ # Double quotes exclude double quotes, single quotes exclude single quotes
50
+ quote_pattern = r'(^|\s)(?:"([^"\u201c\u201d]*)"|\'([^\'\u2018\u2019]*)\')(\s|$|\.|,|;|:|\?|!)'
51
+
52
+ def replace_quotes(match: Match[str]) -> str:
53
+ prefix = match.group(1)
54
+ double_content = match.group(2) # Content of double quotes
55
+ single_content = match.group(3) # Content of single quotes
56
+ suffix = match.group(4)
57
+
58
+ if double_content is not None:
59
+ # Replace double quotes with typographic quotes
60
+ return prefix + "\u201c" + double_content + "\u201d" + suffix
61
+ else:
62
+ # Replace single quotes with typographic quotes
63
+ return prefix + "\u2018" + single_content + "\u2019" + suffix
64
+
65
+ result = re.sub(quote_pattern, replace_quotes, text)
66
+
67
+ # Now handle apostrophes/contractions
68
+ # Only convert single quotes that are:
69
+ # 1. The only quote in the word
70
+ # 2. Have word characters on both sides OR are possessives at end of words ending in s/S
71
+
72
+ # Pattern for apostrophes: word char + ' + word char, where ' is the only quote in the word
73
+ # We need to be careful not to match words that have multiple quotes
74
+
75
+ # Split by whitespace to process words individually
76
+ words = re.split(r"(\s+)", result)
77
+
78
+ for i, word in enumerate(words):
79
+ # Skip whitespace
80
+ if word.isspace():
81
+ continue
82
+
83
+ # Count straight quotes in the word
84
+ quote_count = word.count("'")
85
+
86
+ # Only process if there's exactly one straight quote
87
+ if quote_count == 1:
88
+ # Check if it's surrounded by word characters (contractions)
89
+ apostrophe_pattern = r"(\w)\'(\w)"
90
+ if re.search(apostrophe_pattern, word):
91
+ # Replace the single quote with apostrophe
92
+ words[i] = re.sub(r"\'", "\u2019", word)
93
+ # Check if it's a possessive at the end of a word ending in s/S
94
+ elif re.match(r"\w*[sS]\'$", word):
95
+ # Replace the single quote with apostrophe
96
+ words[i] = re.sub(r"\'", "\u2019", word)
97
+
98
+ return "".join(words)