medium-writer-mcp 0.2.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.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .env
5
+ dist/
6
+ .pytest_cache/
7
+ *.medium.html
8
+ posts/
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 0.2.1 (2026-11-11)
4
+
5
+ First version on PyPI (the 0.2.0 release job never ran).
6
+
7
+ - Subtitle is pasted as a small heading, so Medium styles it as the subtitle
8
+ instead of a section heading.
9
+ - Apostrophes outside code are curled (’) before pasting, so a paragraph no
10
+ longer mixes ' and ’.
11
+ - The "Medium doesn't highlight code" warning is gone (Medium highlights code
12
+ blocks itself). The formatter now only flags code blocks over 30 lines.
13
+
14
+ ## 0.2.0 (2026-11-11)
15
+
16
+ Tagged but not published.
17
+
18
+ - `open_in_medium` (macOS): copies the prepared article to the clipboard as rich
19
+ text and opens a new Medium story in your browser. No login, no automation.
20
+ - `export_article`: writes a paste-ready HTML page, for any OS.
21
+ - `prepare_article`: checks and rewrites Markdown for what Medium supports.
22
+ - `get_user_stories`, `get_publication_stories`, `get_tag_stories`: read Medium's
23
+ public RSS feeds.
24
+ - `draft_from_code` prompt: drafts an article from code in your project.
25
+ - HTTPS uses the OS trust store (`truststore`), so it works with python.org
26
+ Python on macOS.
27
+ - Legacy publishing tools when a pre-2025 `MEDIUM_INTEGRATION_TOKEN` is set.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Muhammad Asher
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.
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.5
2
+ Name: medium-writer-mcp
3
+ Version: 0.2.1
4
+ Summary: MCP server for reading Medium and getting your articles Medium-ready, no API token needed
5
+ Project-URL: Homepage, https://github.com/MuhammadAsher/medium-mcp
6
+ Project-URL: Issues, https://github.com/MuhammadAsher/medium-mcp/issues
7
+ Project-URL: Changelog, https://github.com/MuhammadAsher/medium-mcp/blob/main/CHANGELOG.md
8
+ Author: Muhammad Asher
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: blogging,claude,mcp,medium,model-context-protocol,writing
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: mcp<3,>=2.3
25
+ Requires-Dist: truststore>=0.9
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # medium-writer-mcp
31
+
32
+ [![CI](https://github.com/MuhammadAsher/medium-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/MuhammadAsher/medium-mcp/actions/workflows/ci.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/medium-writer-mcp)](https://pypi.org/project/medium-writer-mcp/)
34
+
35
+ An MCP server that lets Claude read Medium and get your articles Medium-ready.
36
+ Works in Claude Code (terminal or the VS Code extension), the Claude desktop app,
37
+ and any other MCP client.
38
+
39
+ ## How it works (no token needed)
40
+
41
+ 1. **Research**: ask Claude what's being written under a tag or by an author.
42
+ 2. **Draft**: `/mcp__medium__draft_from_code <topic>` writes an article from code in your project.
43
+ 3. **Prepare**: `prepare_article` fixes what Medium can't display (tables, deep headings, checklists)
44
+ and warns about the rest.
45
+ 4. **Open in Medium** (macOS): `open_in_medium` copies the article to your clipboard as
46
+ formatted text and opens [medium.com/new-story](https://medium.com/new-story) in Safari,
47
+ where you're already signed in. Click the title line, press Cmd+V, review, and publish.
48
+ Medium keeps headings, bold, links, lists, quotes, images and code blocks.
49
+ Add tags and subtitle in Medium's publish dialog.
50
+
51
+ Not on macOS? `export_article` writes `<post>.medium.html` instead: open it,
52
+ click in the article, Cmd+A, Cmd+C, and paste into Medium.
53
+
54
+ There's no Medium login in this server, on purpose. Medium no longer gives new apps
55
+ API access, and its site blocks automated browsers, so the server hands the article
56
+ to your normal browser and you do the final paste.
57
+
58
+ ## Tools
59
+
60
+ | Tool | What it does |
61
+ |---|---|
62
+ | `get_user_stories` | Latest stories by an author, with full text (public RSS) |
63
+ | `get_publication_stories` | Latest stories in a publication |
64
+ | `get_tag_stories` | Latest stories under a tag (Medium only includes an excerpt here) |
65
+ | `prepare_article` | Checks Markdown against what Medium supports, fixes what it can, returns warnings, word count and reading time |
66
+ | `export_article` | Runs `prepare_article`, then writes a paste-ready HTML page |
67
+ | `open_in_medium` | macOS: runs `prepare_article`, copies the result as rich text, opens a new Medium story. Replaces your clipboard |
68
+
69
+ Plus one prompt, `draft_from_code`, which shows up as a slash command in Claude Code.
70
+
71
+ **Limits to know**
72
+ - RSS feeds only return the ~10 newest stories. There's no full archive or stats.
73
+ - Medium has no public way to post without an API token, and stopped issuing
74
+ tokens in January 2025, hence the copy-paste step.
75
+
76
+ ## Install
77
+
78
+ Add it to Claude Code for all your projects (needs [uv](https://docs.astral.sh/uv/)):
79
+
80
+ ```bash
81
+ claude mcp add --scope user medium -- uvx medium-writer-mcp
82
+ ```
83
+
84
+ Start a new Claude Code session and type `/mcp` to confirm `medium` is connected.
85
+ It works the same in the VS Code extension and the terminal.
86
+
87
+ For the Claude desktop app or another MCP client, add this to its config:
88
+
89
+ ```json
90
+ {
91
+ "mcpServers": {
92
+ "medium": { "command": "uvx", "args": ["medium-writer-mcp"] }
93
+ }
94
+ }
95
+ ```
96
+
97
+ Without uv: `pip install medium-writer-mcp` and use `medium-writer-mcp` as the command.
98
+
99
+ ### Choosing the browser
100
+
101
+ `open_in_medium` uses Safari unless told otherwise. Pass `browser` per call, or set
102
+ a default with an environment variable (any app name from /Applications, or `default`
103
+ for your system browser):
104
+
105
+ ```bash
106
+ claude mcp add --scope user medium -e MEDIUM_BROWSER="Google Chrome" -- uvx medium-writer-mcp
107
+ ```
108
+
109
+ ## Try it
110
+
111
+ - "What has @username published on Medium lately?"
112
+ - "What's being written under the fastapi tag this week? Summarize the angles."
113
+ - "/mcp__medium__draft_from_code the caching layer in src/cache"
114
+ - "Run prepare_article on docs/post.md and fix the warnings."
115
+ - "Open docs/post.md in Medium."
116
+
117
+ ## Development
118
+
119
+ ```bash
120
+ git clone https://github.com/MuhammadAsher/medium-mcp.git
121
+ cd medium-mcp
122
+ python3 -m venv .venv && source .venv/bin/activate
123
+ pip install -e ".[dev]"
124
+ pytest # 43 tests, all offline
125
+ ```
126
+
127
+ To run your local copy in Claude Code, point the command at `.venv/bin/medium-mcp`.
128
+
129
+ Releasing: bump `__version__` in `src/medium_mcp/__init__.py`, add a CHANGELOG entry,
130
+ then push a matching tag (`git tag v0.2.1 && git push --tags`). GitHub Actions tests,
131
+ builds and publishes to PyPI.
132
+
133
+ ## Project layout
134
+
135
+ ```
136
+ src/medium_mcp/
137
+ server.py MCP tools, prompt, entry point
138
+ feeds.py RSS fetch/parse, HTML -> Markdown (stdlib only)
139
+ formatter.py Medium compatibility checks and rewrites
140
+ export.py Markdown -> paste-ready HTML
141
+ handoff.py macOS clipboard + open browser (osascript, open)
142
+ _http.py HTTPS using the OS trust store
143
+ medium_api.py Legacy REST client (token only)
144
+ tests/ Offline tests with a fixture feed
145
+ ```
146
+
147
+ Dependencies: the `mcp` SDK (v2) and `truststore` (so HTTPS works with python.org
148
+ builds on macOS). HTTP, XML and Markdown use the standard library.
149
+
150
+ ## Legacy: direct publishing with an old token
151
+
152
+ Only if you have an integration token created before January 2025. Setting one adds
153
+ `get_medium_profile`, `list_my_publications`, `upload_image` and `create_post`
154
+ (defaults to draft; Medium's API can't edit or delete posts afterwards).
155
+
156
+ ```json
157
+ "medium": {
158
+ "command": "uvx",
159
+ "args": ["medium-writer-mcp"],
160
+ "env": { "MEDIUM_INTEGRATION_TOKEN": "your-token" }
161
+ }
162
+ ```
163
+
164
+ Don't commit a `.mcp.json` that contains your token. Put it in
165
+ `~/.claude.json` via `claude mcp add --scope user ... -e MEDIUM_INTEGRATION_TOKEN=...`
166
+ instead.
@@ -0,0 +1,137 @@
1
+ # medium-writer-mcp
2
+
3
+ [![CI](https://github.com/MuhammadAsher/medium-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/MuhammadAsher/medium-mcp/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/medium-writer-mcp)](https://pypi.org/project/medium-writer-mcp/)
5
+
6
+ An MCP server that lets Claude read Medium and get your articles Medium-ready.
7
+ Works in Claude Code (terminal or the VS Code extension), the Claude desktop app,
8
+ and any other MCP client.
9
+
10
+ ## How it works (no token needed)
11
+
12
+ 1. **Research**: ask Claude what's being written under a tag or by an author.
13
+ 2. **Draft**: `/mcp__medium__draft_from_code <topic>` writes an article from code in your project.
14
+ 3. **Prepare**: `prepare_article` fixes what Medium can't display (tables, deep headings, checklists)
15
+ and warns about the rest.
16
+ 4. **Open in Medium** (macOS): `open_in_medium` copies the article to your clipboard as
17
+ formatted text and opens [medium.com/new-story](https://medium.com/new-story) in Safari,
18
+ where you're already signed in. Click the title line, press Cmd+V, review, and publish.
19
+ Medium keeps headings, bold, links, lists, quotes, images and code blocks.
20
+ Add tags and subtitle in Medium's publish dialog.
21
+
22
+ Not on macOS? `export_article` writes `<post>.medium.html` instead: open it,
23
+ click in the article, Cmd+A, Cmd+C, and paste into Medium.
24
+
25
+ There's no Medium login in this server, on purpose. Medium no longer gives new apps
26
+ API access, and its site blocks automated browsers, so the server hands the article
27
+ to your normal browser and you do the final paste.
28
+
29
+ ## Tools
30
+
31
+ | Tool | What it does |
32
+ |---|---|
33
+ | `get_user_stories` | Latest stories by an author, with full text (public RSS) |
34
+ | `get_publication_stories` | Latest stories in a publication |
35
+ | `get_tag_stories` | Latest stories under a tag (Medium only includes an excerpt here) |
36
+ | `prepare_article` | Checks Markdown against what Medium supports, fixes what it can, returns warnings, word count and reading time |
37
+ | `export_article` | Runs `prepare_article`, then writes a paste-ready HTML page |
38
+ | `open_in_medium` | macOS: runs `prepare_article`, copies the result as rich text, opens a new Medium story. Replaces your clipboard |
39
+
40
+ Plus one prompt, `draft_from_code`, which shows up as a slash command in Claude Code.
41
+
42
+ **Limits to know**
43
+ - RSS feeds only return the ~10 newest stories. There's no full archive or stats.
44
+ - Medium has no public way to post without an API token, and stopped issuing
45
+ tokens in January 2025, hence the copy-paste step.
46
+
47
+ ## Install
48
+
49
+ Add it to Claude Code for all your projects (needs [uv](https://docs.astral.sh/uv/)):
50
+
51
+ ```bash
52
+ claude mcp add --scope user medium -- uvx medium-writer-mcp
53
+ ```
54
+
55
+ Start a new Claude Code session and type `/mcp` to confirm `medium` is connected.
56
+ It works the same in the VS Code extension and the terminal.
57
+
58
+ For the Claude desktop app or another MCP client, add this to its config:
59
+
60
+ ```json
61
+ {
62
+ "mcpServers": {
63
+ "medium": { "command": "uvx", "args": ["medium-writer-mcp"] }
64
+ }
65
+ }
66
+ ```
67
+
68
+ Without uv: `pip install medium-writer-mcp` and use `medium-writer-mcp` as the command.
69
+
70
+ ### Choosing the browser
71
+
72
+ `open_in_medium` uses Safari unless told otherwise. Pass `browser` per call, or set
73
+ a default with an environment variable (any app name from /Applications, or `default`
74
+ for your system browser):
75
+
76
+ ```bash
77
+ claude mcp add --scope user medium -e MEDIUM_BROWSER="Google Chrome" -- uvx medium-writer-mcp
78
+ ```
79
+
80
+ ## Try it
81
+
82
+ - "What has @username published on Medium lately?"
83
+ - "What's being written under the fastapi tag this week? Summarize the angles."
84
+ - "/mcp__medium__draft_from_code the caching layer in src/cache"
85
+ - "Run prepare_article on docs/post.md and fix the warnings."
86
+ - "Open docs/post.md in Medium."
87
+
88
+ ## Development
89
+
90
+ ```bash
91
+ git clone https://github.com/MuhammadAsher/medium-mcp.git
92
+ cd medium-mcp
93
+ python3 -m venv .venv && source .venv/bin/activate
94
+ pip install -e ".[dev]"
95
+ pytest # 43 tests, all offline
96
+ ```
97
+
98
+ To run your local copy in Claude Code, point the command at `.venv/bin/medium-mcp`.
99
+
100
+ Releasing: bump `__version__` in `src/medium_mcp/__init__.py`, add a CHANGELOG entry,
101
+ then push a matching tag (`git tag v0.2.1 && git push --tags`). GitHub Actions tests,
102
+ builds and publishes to PyPI.
103
+
104
+ ## Project layout
105
+
106
+ ```
107
+ src/medium_mcp/
108
+ server.py MCP tools, prompt, entry point
109
+ feeds.py RSS fetch/parse, HTML -> Markdown (stdlib only)
110
+ formatter.py Medium compatibility checks and rewrites
111
+ export.py Markdown -> paste-ready HTML
112
+ handoff.py macOS clipboard + open browser (osascript, open)
113
+ _http.py HTTPS using the OS trust store
114
+ medium_api.py Legacy REST client (token only)
115
+ tests/ Offline tests with a fixture feed
116
+ ```
117
+
118
+ Dependencies: the `mcp` SDK (v2) and `truststore` (so HTTPS works with python.org
119
+ builds on macOS). HTTP, XML and Markdown use the standard library.
120
+
121
+ ## Legacy: direct publishing with an old token
122
+
123
+ Only if you have an integration token created before January 2025. Setting one adds
124
+ `get_medium_profile`, `list_my_publications`, `upload_image` and `create_post`
125
+ (defaults to draft; Medium's API can't edit or delete posts afterwards).
126
+
127
+ ```json
128
+ "medium": {
129
+ "command": "uvx",
130
+ "args": ["medium-writer-mcp"],
131
+ "env": { "MEDIUM_INTEGRATION_TOKEN": "your-token" }
132
+ }
133
+ ```
134
+
135
+ Don't commit a `.mcp.json` that contains your token. Put it in
136
+ `~/.claude.json` via `claude mcp add --scope user ... -e MEDIUM_INTEGRATION_TOKEN=...`
137
+ instead.
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "medium-writer-mcp"
3
+ dynamic = ["version"]
4
+ description = "MCP server for reading Medium and getting your articles Medium-ready, no API token needed"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Muhammad Asher" }]
10
+ keywords = ["mcp", "medium", "claude", "model-context-protocol", "writing", "blogging"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Topic :: Text Processing :: Markup :: Markdown",
23
+ ]
24
+ dependencies = ["mcp>=2.3,<3", "truststore>=0.9"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/MuhammadAsher/medium-mcp"
28
+ Issues = "https://github.com/MuhammadAsher/medium-mcp/issues"
29
+ Changelog = "https://github.com/MuhammadAsher/medium-mcp/blob/main/CHANGELOG.md"
30
+
31
+ [project.scripts]
32
+ medium-writer-mcp = "medium_mcp.server:main"
33
+ medium-mcp = "medium_mcp.server:main"
34
+
35
+ [project.optional-dependencies]
36
+ dev = ["pytest>=8"]
37
+
38
+ [build-system]
39
+ requires = ["hatchling>=1.27"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.hatch.version]
43
+ path = "src/medium_mcp/__init__.py"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/medium_mcp"]
47
+
48
+ [tool.hatch.build.targets.sdist]
49
+ include = ["src", "tests", "README.md", "CHANGELOG.md", "LICENSE"]
50
+
51
+ [tool.pytest.ini_options]
52
+ pythonpath = ["src"]
53
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """medium-writer-mcp: an MCP server for reading Medium and getting articles Medium-ready."""
2
+
3
+ __version__ = "0.2.1"
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
@@ -0,0 +1,23 @@
1
+ """Shared HTTPS context.
2
+
3
+ Python from python.org on macOS ships without a CA bundle, so plain urllib fails
4
+ with CERTIFICATE_VERIFY_FAILED until "Install Certificates.command" is run.
5
+ truststore verifies against the operating system's trust store instead, which
6
+ works out of the box on macOS, Windows and Linux.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ssl
12
+ import urllib.request
13
+
14
+ try:
15
+ import truststore
16
+
17
+ SSL_CONTEXT: ssl.SSLContext = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
18
+ except ImportError: # pragma: no cover - truststore is a declared dependency
19
+ SSL_CONTEXT = ssl.create_default_context()
20
+
21
+
22
+ def urlopen(req: urllib.request.Request, timeout: float):
23
+ return urllib.request.urlopen(req, timeout=timeout, context=SSL_CONTEXT)
@@ -0,0 +1,218 @@
1
+ """Turn Medium-ready Markdown into an HTML page you can copy-paste into Medium.
2
+
3
+ Medium's editor keeps formatting when you paste rich text from a browser, but
4
+ not when you paste raw Markdown. So the no-token workflow is:
5
+
6
+ prepare_article -> export_article -> open the .html -> Cmd+A, Cmd+C -> paste into Medium
7
+
8
+ This is a deliberately small Markdown converter covering exactly what Medium
9
+ supports: two heading sizes, paragraphs, bold/italic/inline code, links,
10
+ images, bulleted and numbered lists, block quotes, code blocks and dividers.
11
+ Standard library only.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import html
17
+ import re
18
+
19
+ _FENCE_RE = re.compile(r"^\s*(```|~~~)")
20
+ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
21
+ _UL_RE = re.compile(r"^\s*[-*+]\s+(.*)$")
22
+ _OL_RE = re.compile(r"^\s*\d+[.)]\s+(.*)$")
23
+ _HR_RE = re.compile(r"^\s*([-*_])(\s*\1){2,}\s*$")
24
+ _QUOTE_RE = re.compile(r"^\s*>\s?(.*)$")
25
+ _IMAGE_LINE_RE = re.compile(r'^\s*!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\s*$')
26
+
27
+ _PAGE = """<!doctype html>
28
+ <html lang="en">
29
+ <head>
30
+ <meta charset="utf-8">
31
+ <meta name="viewport" content="width=device-width, initial-scale=1">
32
+ <title>{title}</title>
33
+ <style>
34
+ body {{ max-width: 680px; margin: 2rem auto; padding: 0 16px; font: 20px/1.6 Georgia, serif; color: #242424; background: #fff; }}
35
+ .howto {{ font: 14px/1.5 -apple-system, system-ui, sans-serif; background: #f2f7f2; border: 1px solid #cfe3cf;
36
+ border-radius: 6px; padding: 10px 14px; margin-bottom: 2rem; }}
37
+ h1, h2, h3 {{ font-family: -apple-system, system-ui, sans-serif; line-height: 1.25; }}
38
+ pre {{ background: #f2f2f2; padding: 14px; overflow-x: auto; font-size: 15px; }}
39
+ code {{ background: #f2f2f2; padding: 1px 4px; font-size: 0.85em; }}
40
+ pre code {{ background: none; padding: 0; }}
41
+ blockquote {{ border-left: 3px solid #242424; margin-left: 0; padding-left: 20px; font-style: italic; }}
42
+ img {{ max-width: 100%; }}
43
+ figcaption {{ text-align: center; font-size: 15px; color: #6b6b6b; }}
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <div class="howto" contenteditable="false">
48
+ <strong>To paste into Medium:</strong> click inside the article below, press Cmd+A then Cmd+C,
49
+ open <a href="https://medium.com/new-story">medium.com/new-story</a> and press Cmd+V.
50
+ Tags, subtitle and canonical URL go in Medium's publish dialog.
51
+ </div>
52
+ <article contenteditable="true">
53
+ {body}
54
+ </article>
55
+ </body>
56
+ </html>
57
+ """
58
+
59
+
60
+ def markdown_to_html(markdown: str) -> str:
61
+ """Convert Medium-flavoured Markdown to an HTML fragment."""
62
+ lines = markdown.replace("\r\n", "\n").split("\n")
63
+ out: list[str] = []
64
+ para: list[str] = []
65
+ list_kind: str | None = None # "ul" | "ol"
66
+ quote: list[str] = []
67
+ i = 0
68
+
69
+ def flush_para() -> None:
70
+ if para:
71
+ out.append(f"<p>{inline(' '.join(s.strip() for s in para))}</p>")
72
+ para.clear()
73
+
74
+ def close_list() -> None:
75
+ nonlocal list_kind
76
+ if list_kind:
77
+ out.append(f"</{list_kind}>")
78
+ list_kind = None
79
+
80
+ def flush_quote() -> None:
81
+ if quote:
82
+ out.append(f"<blockquote><p>{inline(' '.join(s.strip() for s in quote))}</p></blockquote>")
83
+ quote.clear()
84
+
85
+ def flush_all() -> None:
86
+ flush_para()
87
+ close_list()
88
+ flush_quote()
89
+
90
+ while i < len(lines):
91
+ line = lines[i]
92
+
93
+ fence = _FENCE_RE.match(line)
94
+ if fence:
95
+ flush_all()
96
+ marker = fence.group(1)
97
+ code: list[str] = []
98
+ i += 1
99
+ while i < len(lines) and not lines[i].strip().startswith(marker):
100
+ code.append(lines[i])
101
+ i += 1
102
+ out.append(f"<pre><code>{html.escape(chr(10).join(code))}</code></pre>")
103
+ i += 1 # skip closing fence (or run off the end if unclosed)
104
+ continue
105
+
106
+ if not line.strip():
107
+ flush_all()
108
+ i += 1
109
+ continue
110
+
111
+ m = _HEADING_RE.match(line)
112
+ if m:
113
+ flush_all()
114
+ level = min(len(m.group(1)), 3)
115
+ out.append(f"<h{level}>{inline(m.group(2))}</h{level}>")
116
+ i += 1
117
+ continue
118
+
119
+ if _HR_RE.match(line):
120
+ flush_all()
121
+ out.append("<hr>")
122
+ i += 1
123
+ continue
124
+
125
+ m = _IMAGE_LINE_RE.match(line)
126
+ if m:
127
+ flush_all()
128
+ alt, src, caption = m.group(1), m.group(2), m.group(3)
129
+ fig = f'<figure><img src="{html.escape(src, quote=True)}" alt="{html.escape(alt, quote=True)}">'
130
+ if caption:
131
+ fig += f"<figcaption>{inline(caption)}</figcaption>"
132
+ out.append(fig + "</figure>")
133
+ i += 1
134
+ continue
135
+
136
+ m = _QUOTE_RE.match(line)
137
+ if m:
138
+ flush_para()
139
+ close_list()
140
+ quote.append(m.group(1))
141
+ i += 1
142
+ continue
143
+ flush_quote()
144
+
145
+ ul, ol = _UL_RE.match(line), _OL_RE.match(line)
146
+ if ul or ol:
147
+ flush_para()
148
+ kind = "ul" if ul else "ol"
149
+ if list_kind != kind:
150
+ close_list()
151
+ out.append(f"<{kind}>")
152
+ list_kind = kind
153
+ out.append(f"<li>{inline((ul or ol).group(1))}</li>")
154
+ i += 1
155
+ continue
156
+
157
+ if list_kind and line.startswith((" ", "\t")):
158
+ # Continuation of the previous list item: append to it.
159
+ out[-1] = out[-1][: -len("</li>")] + " " + inline(line.strip()) + "</li>"
160
+ i += 1
161
+ continue
162
+
163
+ close_list()
164
+ para.append(line)
165
+ i += 1
166
+
167
+ flush_all()
168
+ return "\n".join(out)
169
+
170
+
171
+ def inline(text: str) -> str:
172
+ """Inline Markdown -> HTML. Code spans are protected from other rules."""
173
+ spans: list[str] = []
174
+
175
+ def stash_code(m: re.Match) -> str:
176
+ spans.append(f"<code>{html.escape(m.group(1))}</code>")
177
+ return f"\x00{len(spans) - 1}\x00"
178
+
179
+ text = re.sub(r"`([^`]+)`", stash_code, text)
180
+ text = html.escape(text, quote=False)
181
+ text = re.sub(
182
+ r"!\[([^\]]*)\]\(([^)\s]+)[^)]*\)",
183
+ lambda m: f'<img src="{_attr(m.group(2))}" alt="{_attr(m.group(1))}">',
184
+ text,
185
+ )
186
+ text = re.sub(
187
+ r"\[([^\]]+)\]\(([^)\s]+)[^)]*\)",
188
+ lambda m: f'<a href="{_attr(m.group(2))}">{m.group(1)}</a>',
189
+ text,
190
+ )
191
+ text = re.sub(r"(\*\*|__)(?=\S)(.+?)(?<=\S)\1", r"<strong>\2</strong>", text)
192
+ text = re.sub(r"(?<![\w*])\*(?=\S)(.+?)(?<=\S)\*(?![\w*])", r"<em>\1</em>", text)
193
+ text = re.sub(r"(?<![\w_])_(?=\S)(.+?)(?<=\S)_(?![\w_])", r"<em>\1</em>", text)
194
+ text = re.sub(r"~~(?=\S)(.+?)(?<=\S)~~", r"<s>\1</s>", text)
195
+ text = _curl_apostrophes(text)
196
+ return re.sub(r"\x00(\d+)\x00", lambda m: spans[int(m.group(1))], text)
197
+
198
+
199
+ def _curl_apostrophes(text: str) -> str:
200
+ """Turn word-internal ' into ’ outside tags (code spans are already stashed).
201
+
202
+ Medium curls apostrophes on paste, but not reliably after an inline code
203
+ span, which leaves a mix of ' and ’ in one paragraph. Doing it here keeps
204
+ them consistent.
205
+ """
206
+ parts = re.split(r"(<[^>]*>)", text)
207
+ return "".join(
208
+ p if p.startswith("<") else re.sub(r"(?<=\w)'(?=\w)", "\u2019", p) for p in parts
209
+ )
210
+
211
+
212
+ def _attr(value: str) -> str:
213
+ # Input is already HTML-escaped (minus quotes); only quotes need escaping here.
214
+ return value.replace('"', "&quot;")
215
+
216
+
217
+ def render_page(title: str, body_html: str) -> str:
218
+ return _PAGE.format(title=html.escape(title or "Medium draft"), body=body_html)