md2plain 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.
md2plain/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Convert Markdown into plain-text email."""
2
+
3
+ __version__ = "0.1.0"
md2plain/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
md2plain/cli.py ADDED
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python3
2
+ """Convert Markdown to a plain-text email and copy it to the macOS clipboard.
3
+
4
+ Only text/plain is placed on the clipboard, so pasting into Gmail's
5
+ "Plain text mode" produces a real text/plain message.
6
+
7
+ Usage:
8
+ md2plain note.md
9
+ md2plain note.md --width 72
10
+ md2plain note.md --watch
11
+ md2plain note.md --print
12
+ md2plain note.md -o out.txt
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import os
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+
23
+ from . import __version__
24
+ from .render import render_plain
25
+
26
+
27
+ def positive_width(value: str) -> int:
28
+ width = int(value)
29
+ if width < 1:
30
+ raise argparse.ArgumentTypeError("width must be at least 1")
31
+ return width
32
+
33
+
34
+ def read_input(path: str) -> str:
35
+ if path == "-":
36
+ return sys.stdin.read()
37
+ try:
38
+ return Path(path).read_text(encoding="utf-8-sig", errors="replace")
39
+ except FileNotFoundError:
40
+ sys.exit(f"File not found: {path}")
41
+ except OSError as exc:
42
+ sys.exit(f"Cannot read {path}: {exc}")
43
+
44
+
45
+ def copy_text(text: str) -> None:
46
+ """Write plain text only, so Gmail's plain-text mode gets it verbatim."""
47
+ from AppKit import NSPasteboard, NSPasteboardTypeString
48
+
49
+ name = os.environ.get("MD2PLAIN_PASTEBOARD", "general")
50
+ board = (
51
+ NSPasteboard.generalPasteboard()
52
+ if name == "general"
53
+ else NSPasteboard.pasteboardWithName_(name)
54
+ )
55
+ board.clearContents()
56
+ board.declareTypes_owner_([NSPasteboardTypeString], None)
57
+ board.setString_forType_(text, NSPasteboardTypeString)
58
+
59
+
60
+ def finish(text: str, args: argparse.Namespace) -> None:
61
+ if args.output:
62
+ Path(args.output).write_text(text, encoding="utf-8")
63
+ if args.show:
64
+ sys.stdout.write(text)
65
+ if args.no_copy:
66
+ return
67
+
68
+ try:
69
+ copy_text(text)
70
+ except ImportError:
71
+ sys.exit("pyobjc is not installed; use --print or -o instead")
72
+
73
+ print(
74
+ f"Copied {len(text.splitlines())} lines / {len(text)} chars of plain text. "
75
+ "Enable Gmail's Plain text mode, then press Cmd+V.",
76
+ file=sys.stderr,
77
+ )
78
+
79
+
80
+ def main() -> int:
81
+ parser = argparse.ArgumentParser(
82
+ prog="md2plain",
83
+ description="Convert Markdown to plain-text email and copy it to the clipboard.",
84
+ )
85
+ parser.add_argument(
86
+ "--version", action="version", version=f"%(prog)s {__version__}"
87
+ )
88
+ parser.add_argument("input", help="Markdown file, or - for stdin")
89
+ parser.add_argument("-o", "--output", help="also write the plain text to a file")
90
+ parser.add_argument(
91
+ "--print", dest="show", action="store_true", help="write to stdout"
92
+ )
93
+ parser.add_argument(
94
+ "--no-copy", action="store_true", help="do not touch the clipboard"
95
+ )
96
+ parser.add_argument(
97
+ "--watch", action="store_true", help="update the clipboard on save"
98
+ )
99
+ parser.add_argument(
100
+ "--width", type=positive_width, default=75, help="line width (default: 75)"
101
+ )
102
+ parser.add_argument(
103
+ "--soft-breaks",
104
+ action="store_true",
105
+ help="treat a single newline as a space (default: keep line breaks)",
106
+ )
107
+ args = parser.parse_args()
108
+
109
+ if args.watch and args.input == "-":
110
+ parser.error("--watch requires a file, not stdin")
111
+
112
+ text = read_input(args.input)
113
+ if not text.strip():
114
+ sys.exit("Input is empty.")
115
+
116
+ preserve_breaks = not args.soft_breaks
117
+
118
+ def convert(src: str) -> None:
119
+ finish(render_plain(src, args.width, preserve_breaks), args)
120
+
121
+ if not args.watch:
122
+ convert(text)
123
+ return 0
124
+
125
+ last_mtime: float | None = None
126
+ try:
127
+ while True:
128
+ try:
129
+ mtime = os.stat(args.input).st_mtime
130
+ except FileNotFoundError:
131
+ time.sleep(1)
132
+ continue
133
+ if mtime != last_mtime:
134
+ convert(read_input(args.input))
135
+ last_mtime = mtime
136
+ time.sleep(1)
137
+ except KeyboardInterrupt:
138
+ print("\nStopped watching.", file=sys.stderr)
139
+ return 0
140
+
141
+
142
+ if __name__ == "__main__":
143
+ raise SystemExit(main())
md2plain/render.py ADDED
@@ -0,0 +1,280 @@
1
+ """Render Markdown as plain-text email in reStructuredText style.
2
+
3
+ Headings use = / - / ~ underlines, paragraphs are hard-wrapped at 75
4
+ columns (CJK counts as two), lists use "*", quotes use ">", and code is
5
+ indented by four spaces.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import unicodedata
12
+
13
+ from markdown_it import MarkdownIt
14
+ from markdown_it.tree import SyntaxTreeNode
15
+
16
+ UNDERLINE = {1: "=", 2: "-", 3: "~", 4: "^", 5: '"', 6: "'"}
17
+ QUOTE = ">"
18
+
19
+ # Punctuation that must not start a line (CJK line-breaking rule).
20
+ NO_LINE_START = set("。,、;:!?)〕]}》」』】’”…·%,.;:!?)]}'\"")
21
+ # Link schemes that are safe to keep.
22
+ SAFE_LINK = re.compile(r"^(https?://|mailto:|#)", re.IGNORECASE)
23
+
24
+
25
+ def char_width(ch: str) -> int:
26
+ """Display width of one character: 2 for wide (CJK) characters."""
27
+ return 2 if unicodedata.east_asian_width(ch) in "WF" else 1
28
+
29
+
30
+ def disp_width(text: str) -> int:
31
+ return sum(char_width(c) for c in text)
32
+
33
+
34
+ # --------------------------------------------------------------------- inline
35
+
36
+
37
+ def inline_text(node: SyntaxTreeNode, with_urls: bool = True) -> str:
38
+ """Flatten an inline node to text; links become "label (url)"."""
39
+ kind = node.type
40
+
41
+ if kind in ("text", "code_inline"):
42
+ return node.content
43
+ if kind == "softbreak":
44
+ return " "
45
+ if kind == "hardbreak":
46
+ return "\n"
47
+ if kind == "image":
48
+ return node.content or (node.attrs.get("src") or "")
49
+ if kind == "html_inline":
50
+ return ""
51
+ if kind == "link":
52
+ label = "".join(inline_text(c, False) for c in node.children)
53
+ href = node.attrs.get("href") or ""
54
+ if not with_urls or not href or not SAFE_LINK.match(href):
55
+ return label
56
+ # Do not repeat a bare address: mailto:a@b.com with label a@b.com.
57
+ if href.lower().startswith("mailto:") and href[7:] == label:
58
+ return label
59
+ # A URL used as its own label needs no "(url)" suffix, even when the
60
+ # target only differs by tracking parameters.
61
+ if label.startswith(("http://", "https://")):
62
+ base = href.split("?", 1)[0].split("#", 1)[0].rstrip("/")
63
+ if base == label.rstrip("/"):
64
+ return label
65
+ if href != label:
66
+ return f"{label} ({href})"
67
+ return label
68
+
69
+ return "".join(inline_text(c, with_urls) for c in node.children)
70
+
71
+
72
+ def plain_of(node: SyntaxTreeNode) -> str:
73
+ return inline_text(node, with_urls=False)
74
+
75
+
76
+ # ----------------------------------------------------------------------- wrap
77
+
78
+
79
+ def wrap_line(line: str, width: int) -> list[str]:
80
+ """Wrap one line at spaces, breaking CJK text between characters."""
81
+ line = re.sub(r"[ \t]+", " ", line).strip()
82
+ if not line:
83
+ return [""]
84
+
85
+ out: list[str] = []
86
+ current: list[str] = []
87
+ current_width = 0
88
+ last_space = -1
89
+
90
+ for ch in line:
91
+ w = char_width(ch)
92
+ if current and current_width + w > width:
93
+ if ch in NO_LINE_START:
94
+ current.append(ch)
95
+ current_width += w
96
+ continue
97
+
98
+ if last_space > 0:
99
+ out.append("".join(current[:last_space]).rstrip())
100
+ current = current[last_space + 1 :]
101
+ else:
102
+ out.append("".join(current).rstrip())
103
+ current = []
104
+
105
+ current_width = sum(char_width(c) for c in current)
106
+ last_space = -1
107
+ for i, c in enumerate(current):
108
+ if c == " ":
109
+ last_space = i
110
+
111
+ current.append(ch)
112
+ current_width += w
113
+ if ch == " ":
114
+ last_space = len(current) - 1
115
+
116
+ if current:
117
+ out.append("".join(current).rstrip())
118
+ # Very small widths can produce blank lines; keep the result non-empty.
119
+ return [line for line in out if line] or [""]
120
+
121
+
122
+ def wrap_text(text: str, width: int) -> list[str]:
123
+ lines: list[str] = []
124
+ for part in text.split("\n"):
125
+ lines.extend(wrap_line(part, width))
126
+ return lines
127
+
128
+
129
+ # --------------------------------------------------------------------- blocks
130
+
131
+
132
+ def render_blocks(nodes: list[SyntaxTreeNode], width: int) -> list[str]:
133
+ out: list[str] = []
134
+
135
+ for node in nodes:
136
+ kind = node.type
137
+
138
+ if kind == "heading":
139
+ level = int(node.tag[1])
140
+ text = plain_of(node.children[0]).strip()
141
+ if not text:
142
+ continue
143
+ mark = UNDERLINE.get(level, "-")
144
+ out += [text, mark * max(disp_width(text), 3), ""]
145
+
146
+ elif kind == "paragraph":
147
+ out += wrap_text(inline_text(node.children[0]), width)
148
+ out.append("")
149
+
150
+ elif kind in ("bullet_list", "ordered_list"):
151
+ out += render_list(node, width)
152
+
153
+ elif kind == "blockquote":
154
+ body = render_blocks(node.children, width - 2)
155
+ while body and not body[-1]:
156
+ body.pop()
157
+ out += [QUOTE if not line else f"{QUOTE} {line}" for line in body]
158
+ out.append("")
159
+
160
+ elif kind in ("fence", "code_block"):
161
+ code = (node.content or "").rstrip("\n").split("\n")
162
+ out += [" " + line for line in code]
163
+ out.append("")
164
+
165
+ elif kind == "hr":
166
+ out += ["-" * min(width, 72), ""]
167
+
168
+ elif kind == "table":
169
+ out += render_table(node, width)
170
+ out.append("")
171
+
172
+ elif kind == "html_block":
173
+ continue
174
+
175
+ elif node.children:
176
+ out += render_blocks(node.children, width)
177
+
178
+ return out
179
+
180
+
181
+ def render_list(node: SyntaxTreeNode, width: int) -> list[str]:
182
+ ordered = node.type == "ordered_list"
183
+ start = int(node.attrs.get("start", 1)) if ordered else None
184
+ out: list[str] = []
185
+
186
+ for i, item in enumerate(node.children):
187
+ marker = f"{start + i}. " if start is not None else "* "
188
+ body = render_blocks(item.children, width - len(marker))
189
+ while body and not body[-1]:
190
+ body.pop()
191
+
192
+ # Keep nested lists tight: drop the blank line before them.
193
+ tight: list[str] = []
194
+ for k, line in enumerate(body):
195
+ if not line:
196
+ nxt = k + 1
197
+ while nxt < len(body) and not body[nxt]:
198
+ nxt += 1
199
+ if nxt < len(body) and re.match(r"^(\* |\d+\. )", body[nxt]):
200
+ continue
201
+ tight.append(line)
202
+
203
+ for j, line in enumerate(tight):
204
+ out.append((marker if j == 0 else " " * len(marker)) + line)
205
+
206
+ out.append("")
207
+ return out
208
+
209
+
210
+ def render_table(node: SyntaxTreeNode, width: int) -> list[str]:
211
+ rows: list[list[str]] = []
212
+ for section in node.children:
213
+ for tr in section.children:
214
+ cells = [
215
+ "".join(plain_of(c) for c in cell.children).strip()
216
+ for cell in tr.children
217
+ ]
218
+ if cells:
219
+ rows.append(cells)
220
+ if not rows:
221
+ return []
222
+
223
+ ncols = max(len(row) for row in rows)
224
+ for row in rows:
225
+ row.extend([""] * (ncols - len(row)))
226
+
227
+ widths = [max(disp_width(row[c]) for row in rows) for c in range(ncols)]
228
+ while sum(widths) + 2 * (ncols - 1) > width and max(widths) > 4:
229
+ widths[widths.index(max(widths))] -= 1
230
+
231
+ def fmt(row: list[str]) -> str:
232
+ padded = []
233
+ for c, cell in enumerate(row):
234
+ padded.append(cell + " " * max(widths[c] - disp_width(cell), 0))
235
+ return " ".join(padded).rstrip()
236
+
237
+ lines = [fmt(rows[0]), " ".join("-" * w for w in widths)]
238
+ lines += [fmt(row) for row in rows[1:]]
239
+ return lines
240
+
241
+
242
+ # --------------------------------------------------------------------- output
243
+
244
+
245
+ def parse(md_text: str, preserve_breaks: bool = True) -> SyntaxTreeNode:
246
+ md = MarkdownIt(
247
+ "commonmark", {"html": False, "linkify": True, "typographer": False}
248
+ )
249
+ md.enable(["table", "strikethrough", "linkify"])
250
+ tokens = md.parse(md_text)
251
+
252
+ if preserve_breaks:
253
+ # A single newline usually means a real line break here (signatures,
254
+ # dates), unlike CommonMark's default of joining it with a space.
255
+ for token in tokens:
256
+ if token.type == "inline" and token.children:
257
+ for child in token.children:
258
+ if child.type == "softbreak":
259
+ child.type = "hardbreak"
260
+
261
+ return SyntaxTreeNode(tokens)
262
+
263
+
264
+ def tidy(lines: list[str]) -> list[str]:
265
+ out: list[str] = []
266
+ for line in lines:
267
+ line = line.rstrip()
268
+ if not line and out and not out[-1]:
269
+ continue
270
+ out.append(line)
271
+ while out and not out[0]:
272
+ out.pop(0)
273
+ while out and not out[-1]:
274
+ out.pop()
275
+ return out
276
+
277
+
278
+ def render_plain(md_text: str, width: int = 75, preserve_breaks: bool = True) -> str:
279
+ lines = tidy(render_blocks(parse(md_text, preserve_breaks).children, width))
280
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,201 @@
1
+ Metadata-Version: 2.5
2
+ Name: md2plain
3
+ Version: 0.1.0
4
+ Summary: Convert Markdown to plain-text email for Gmail's Plain text mode.
5
+ Project-URL: Homepage, https://github.com/hoshF/md2plain
6
+ Project-URL: Issues, https://github.com/hoshF/md2plain/issues
7
+ Author-email: hoshF <noveor.ol@gmail.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 hoshF
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: cli,email,gmail,markdown,plain-text
31
+ Classifier: Environment :: Console
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Operating System :: MacOS
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.10
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Topic :: Communications :: Email
39
+ Classifier: Topic :: Text Processing :: Markup
40
+ Requires-Python: >=3.10
41
+ Requires-Dist: linkify-it-py>=2.0
42
+ Requires-Dist: markdown-it-py>=3.0
43
+ Requires-Dist: pyobjc-framework-cocoa>=10.0; sys_platform == 'darwin'
44
+ Provides-Extra: dev
45
+ Requires-Dist: black>=24; extra == 'dev'
46
+ Requires-Dist: ruff>=0.6; extra == 'dev'
47
+ Description-Content-Type: text/markdown
48
+
49
+ # md2plain
50
+
51
+ [![CI](https://github.com/hoshF/md2plain/actions/workflows/ci.yml/badge.svg)](https://github.com/hoshF/md2plain/actions/workflows/ci.yml)
52
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
53
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
54
+ [![Platform: macOS](https://img.shields.io/badge/platform-macOS-lightgrey)](pyproject.toml)
55
+
56
+ **Markdown in, plain-text email out.**
57
+
58
+ `md2plain` renders Markdown as a plain-text email and copies it to the macOS
59
+ clipboard as `text/plain`. Paste it into Gmail's **Plain text mode** and the
60
+ message is sent as a real `text/plain` message: hard-wrapped, with no HTML,
61
+ no fonts, and no client-side reflow.
62
+
63
+ ## Demo
64
+
65
+ Input:
66
+
67
+ ```markdown
68
+ # Quarterly Update
69
+
70
+ This is a **short** note with a [link](https://example.com).
71
+
72
+ * one
73
+ * two
74
+
75
+ > quoted
76
+ ```
77
+
78
+ Output:
79
+
80
+ ```text
81
+ Quarterly Update
82
+ ================
83
+
84
+ This is a short note with a link (https://example.com).
85
+
86
+ * one
87
+ * two
88
+
89
+ > quoted
90
+ ```
91
+
92
+ ## Why
93
+
94
+ - Gmail's normal compose always sends HTML, even when you paste plain text.
95
+ - Rich text copied from Typora carries its own CSS, which mail clients mangle.
96
+ - Plain text has no automatic reflow, so the sender has to control line width.
97
+ - A 75-column limit leaves room for `> ` quote prefixes and stays within
98
+ RFC 5322's recommended 78 characters.
99
+
100
+ ## Requirements
101
+
102
+ - macOS (the clipboard is written through AppKit)
103
+ - Python 3.10 or newer
104
+
105
+ ## Install
106
+
107
+ With [pipx](https://pipx.pypa.io/):
108
+
109
+ ```bash
110
+ pipx install .
111
+ ```
112
+
113
+ Or from a checkout, in a virtual environment:
114
+
115
+ ```bash
116
+ python3 -m venv .venv
117
+ .venv/bin/pip install -e .
118
+ ```
119
+
120
+ ## Usage
121
+
122
+ ```bash
123
+ md2plain note.md
124
+ ```
125
+
126
+ Then in Gmail: open the compose window, click the three-dot menu, choose
127
+ **Plain text mode**, and press `Cmd+V`.
128
+
129
+ To verify, send a message to yourself and use **Show original**. The message
130
+ should contain only:
131
+
132
+ ```text
133
+ Content-Type: text/plain; charset="UTF-8"
134
+ ```
135
+
136
+ No `multipart/alternative`, no `text/html`.
137
+
138
+ ## Options
139
+
140
+ | Option | Description |
141
+ |---|---|
142
+ | `--width N` | Line width in columns (default: 75) |
143
+ | `--watch` | Update the clipboard whenever the file is saved |
144
+ | `--print` | Write to stdout instead of the clipboard |
145
+ | `-o, --output FILE` | Also write the plain text to a file |
146
+ | `--soft-breaks` | Treat single newlines as spaces |
147
+ | `--version` | Print the version and exit |
148
+
149
+ ## Output format
150
+
151
+ | Markdown | Output |
152
+ |---|---|
153
+ | `# Heading` | heading + `====` underline |
154
+ | `## Heading` | heading + `----` underline |
155
+ | `### Heading` | heading + `~~~~` underline |
156
+ | Paragraph | hard-wrapped at 75 columns (CJK counts as two) |
157
+ | `- item` | `* item`, with hanging indent |
158
+ | `1. item` | `1. item` |
159
+ | `> quote` | `> quote` |
160
+ | Code block | indented by four spaces |
161
+ | `[text](url)` | `text (url)` |
162
+ | Table | column-aligned plain text |
163
+ | Single newline | kept (signatures and dates stay on their own lines) |
164
+
165
+ ## How it works
166
+
167
+ 1. `markdown-it-py` parses CommonMark plus tables, strikethrough, and
168
+ autolinks.
169
+ 2. `src/md2plain/render.py` wraps text by display width (CJK counts as two
170
+ columns), renders headings, lists, quotes, and tables.
171
+ 3. `src/md2plain/cli.py` reads UTF-8 (BOM tolerant) and writes exactly one
172
+ clipboard type: `text/plain`.
173
+
174
+ ## Limitations
175
+
176
+ - macOS only; the clipboard backend is AppKit.
177
+ - Plain text has no bold, italics, or clickable links. Link URLs are kept as
178
+ text, and links inside headings or table cells lose the URL.
179
+ - Images become alt text. YAML front matter, footnotes, math, and Mermaid
180
+ diagrams are not converted.
181
+ - Tables are aligned with spaces, so proportional fonts break the alignment.
182
+ - Gmail may collapse `>` quote blocks behind a three-dot control; this is
183
+ display only and the content stays intact.
184
+
185
+ ## Development
186
+
187
+ ```bash
188
+ python3 -m venv .venv
189
+ .venv/bin/pip install -e ".[dev]"
190
+ .venv/bin/python -m unittest discover -s tests -v
191
+ .venv/bin/ruff check src tests
192
+ .venv/bin/black --check src tests
193
+ ```
194
+
195
+ The `./md2plain` script in the repository root is a development launcher; it
196
+ runs the package from `.venv`. Installed users get the `md2plain` command from
197
+ `pyproject.toml`.
198
+
199
+ ## License
200
+
201
+ [MIT](LICENSE). See [CHANGELOG.md](CHANGELOG.md) for release history.
@@ -0,0 +1,9 @@
1
+ md2plain/__init__.py,sha256=cf1lnlvj00XKs2SZvzGpZeoYCKmNbgW7EBzgzasGNrQ,69
2
+ md2plain/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ md2plain/cli.py,sha256=cafYGn2QUUGTWZuiaeORxaAY8EAyiS6EsXizAIczPgM,4115
4
+ md2plain/render.py,sha256=MBOcaIn93exuHtlYnokwertziBERQF0QdNhq0b9lTlI,8925
5
+ md2plain-0.1.0.dist-info/METADATA,sha256=PByUSBWwq-jKCrCpGJoEDm0i6XzWwEjDzVw8YYHVo3Q,6505
6
+ md2plain-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ md2plain-0.1.0.dist-info/entry_points.txt,sha256=I92mxKS_-_eLAu5PleHXkvN46RkSuCHK8Fkj2NgPeKM,47
8
+ md2plain-0.1.0.dist-info/licenses/LICENSE,sha256=yz5qS2XkiEU3inW-ky4zpr1M2rXdnhxI8NkMPBgFsoM,1062
9
+ md2plain-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ md2plain = md2plain.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hoshF
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.