linkedown 0.1.0__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,22 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.pyo
4
+ *.pyd
5
+ .Python
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .eggs/
10
+ .env
11
+ .venv
12
+ venv/
13
+ *.egg
14
+ .installed.cfg
15
+ *.dist-info/
16
+ .pytest_cache/
17
+ .mypy_cache/
18
+ .ruff_cache/
19
+ htmlcov/
20
+ .coverage
21
+ *.cover
22
+ .hypothesis/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gregory R. Warnes
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,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: linkedown
3
+ Version: 0.1.0
4
+ Summary: Bidirectional Markdown ↔ LinkedIn Unicode converter. CLI and MCP tools.
5
+ Project-URL: Homepage, https://github.com/Warnes-Innovations/linkedown
6
+ Project-URL: Repository, https://github.com/Warnes-Innovations/linkedown
7
+ Project-URL: Issues, https://github.com/Warnes-Innovations/linkedown/issues
8
+ Author-email: "Gregory R. Warnes" <greg@warnes-innovations.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,converter,linkedin,markdown,mcp,unicode
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
21
+ Classifier: Topic :: Utilities
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: click>=8.0
25
+ Requires-Dist: mcp>=1.0
26
+ Requires-Dist: mistune>=3.0
27
+ Requires-Dist: rich>=13.0
28
+ Provides-Extra: clipboard
29
+ Requires-Dist: pyperclip; extra == 'clipboard'
30
+ Provides-Extra: dev
31
+ Requires-Dist: hatch; extra == 'dev'
32
+ Requires-Dist: mypy; extra == 'dev'
33
+ Requires-Dist: pytest-cov; extra == 'dev'
34
+ Requires-Dist: pytest>=8.0; extra == 'dev'
35
+ Requires-Dist: ruff; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # linkedown
39
+
40
+ Bidirectional **Markdown ↔ LinkedIn Unicode** converter.
41
+
42
+ LinkedIn does not render Markdown, but does display Unicode styled characters correctly.
43
+ `linkedown` converts between the two formats — both as a CLI tool and as an MCP server
44
+ for use by coding agents.
45
+
46
+ The Markdown → LinkedIn conversion logic is derived from
47
+ [md-to-linkedin](https://github.com/shenning00/md-to-linkedin) by Scott Henning (MIT).
48
+
49
+ ## Installation
50
+
51
+ ```sh
52
+ pip install linkedown
53
+ ```
54
+
55
+ ## CLI usage
56
+
57
+ ```sh
58
+ # Markdown → LinkedIn
59
+ md2li post.md # print to stdout
60
+ md2li post.md -o linkedin.txt # write to file
61
+ md2li post.md --copy # copy to clipboard
62
+
63
+ # LinkedIn → Markdown
64
+ li2md post.txt
65
+ li2md post.txt -o post.md
66
+
67
+ # Pipe
68
+ cat post.md | md2li
69
+ ```
70
+
71
+ ## MCP server
72
+
73
+ ```sh
74
+ linkedown-mcp # stdio server for Copilot/Claude/Codex/Cline
75
+ uvx linkedown linkedown-mcp
76
+ ```
77
+
78
+ Tools exposed:
79
+ - `md_to_linkedin_tool` — Markdown → LinkedIn Unicode
80
+ - `linkedin_to_md_tool` — LinkedIn Unicode → Markdown
81
+
82
+ ## Supported formatting
83
+
84
+ | Markdown | LinkedIn output |
85
+ |----------------|------------------------|
86
+ | `**bold**` | 𝗯𝗼𝗹𝗱 (sans-serif bold) |
87
+ | `*italic*` | 𝘪𝘵𝘢𝘭𝘪𝘤 |
88
+ | `` `code` `` | 𝚌𝚘𝚍𝚎 (monospace) |
89
+ | `# Heading` | **𝗛𝗲𝗮𝗱𝗶𝗻𝗴** |
90
+ | `- item` | • item |
91
+ | `` ``` `` block | ▸ prefixed lines |
92
+ | `> quote` | │ quote |
93
+ | `[text](url)` | text (url) |
94
+ | `---` | ───────────── |
95
+
96
+ ## License
97
+
98
+ MIT — see [LICENSE](LICENSE).
99
+
100
+ Attribution: Markdown → LinkedIn conversion logic derived from
101
+ [shenning00/md-to-linkedin](https://github.com/shenning00/md-to-linkedin) (MIT).
@@ -0,0 +1,64 @@
1
+ # linkedown
2
+
3
+ Bidirectional **Markdown ↔ LinkedIn Unicode** converter.
4
+
5
+ LinkedIn does not render Markdown, but does display Unicode styled characters correctly.
6
+ `linkedown` converts between the two formats — both as a CLI tool and as an MCP server
7
+ for use by coding agents.
8
+
9
+ The Markdown → LinkedIn conversion logic is derived from
10
+ [md-to-linkedin](https://github.com/shenning00/md-to-linkedin) by Scott Henning (MIT).
11
+
12
+ ## Installation
13
+
14
+ ```sh
15
+ pip install linkedown
16
+ ```
17
+
18
+ ## CLI usage
19
+
20
+ ```sh
21
+ # Markdown → LinkedIn
22
+ md2li post.md # print to stdout
23
+ md2li post.md -o linkedin.txt # write to file
24
+ md2li post.md --copy # copy to clipboard
25
+
26
+ # LinkedIn → Markdown
27
+ li2md post.txt
28
+ li2md post.txt -o post.md
29
+
30
+ # Pipe
31
+ cat post.md | md2li
32
+ ```
33
+
34
+ ## MCP server
35
+
36
+ ```sh
37
+ linkedown-mcp # stdio server for Copilot/Claude/Codex/Cline
38
+ uvx linkedown linkedown-mcp
39
+ ```
40
+
41
+ Tools exposed:
42
+ - `md_to_linkedin_tool` — Markdown → LinkedIn Unicode
43
+ - `linkedin_to_md_tool` — LinkedIn Unicode → Markdown
44
+
45
+ ## Supported formatting
46
+
47
+ | Markdown | LinkedIn output |
48
+ |----------------|------------------------|
49
+ | `**bold**` | 𝗯𝗼𝗹𝗱 (sans-serif bold) |
50
+ | `*italic*` | 𝘪𝘵𝘢𝘭𝘪𝘤 |
51
+ | `` `code` `` | 𝚌𝚘𝚍𝚎 (monospace) |
52
+ | `# Heading` | **𝗛𝗲𝗮𝗱𝗶𝗻𝗴** |
53
+ | `- item` | • item |
54
+ | `` ``` `` block | ▸ prefixed lines |
55
+ | `> quote` | │ quote |
56
+ | `[text](url)` | text (url) |
57
+ | `---` | ───────────── |
58
+
59
+ ## License
60
+
61
+ MIT — see [LICENSE](LICENSE).
62
+
63
+ Attribution: Markdown → LinkedIn conversion logic derived from
64
+ [shenning00/md-to-linkedin](https://github.com/shenning00/md-to-linkedin) (MIT).
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "linkedown"
3
+ version = "0.1.0"
4
+ description = "Bidirectional Markdown ↔ LinkedIn Unicode converter. CLI and MCP tools."
5
+ readme = {file = "README.md", content-type = "text/markdown"}
6
+ authors = [{name = "Gregory R. Warnes", email = "greg@warnes-innovations.com"}]
7
+ license = {text = "MIT"}
8
+ license-files = ["LICENSE"]
9
+ requires-python = ">=3.11"
10
+ keywords = ["linkedin", "markdown", "converter", "unicode", "mcp", "cli"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Topic :: Text Processing :: Markup :: Markdown",
21
+ "Topic :: Utilities",
22
+ "Typing :: Typed",
23
+ ]
24
+ # md→li direction uses mistune + click + rich (same as upstream md-to-linkedin).
25
+ # li→md direction is pure stdlib; no extra deps.
26
+ # mcp dependency is for the MCP server.
27
+ dependencies = [
28
+ "click>=8.0",
29
+ "mistune>=3.0",
30
+ "mcp>=1.0",
31
+ "rich>=13.0",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ clipboard = ["pyperclip"]
36
+ dev = ["pytest>=8.0", "pytest-cov", "ruff", "mypy", "hatch"]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/Warnes-Innovations/linkedown"
40
+ Repository = "https://github.com/Warnes-Innovations/linkedown"
41
+ Issues = "https://github.com/Warnes-Innovations/linkedown/issues"
42
+
43
+ [project.scripts]
44
+ md2li = "linkedown.cli:md2li_main"
45
+ li2md = "linkedown.cli:li2md_main"
46
+ linkedown-mcp = "linkedown.server:main"
47
+
48
+ [build-system]
49
+ requires = ["hatchling"]
50
+ build-backend = "hatchling.build"
51
+
52
+ [tool.hatch.build.targets.sdist]
53
+ include = ["/LICENSE", "/README.md", "/src"]
54
+
55
+ [tool.ruff]
56
+ line-length = 100
57
+ target-version = "py311"
58
+
59
+ [tool.mypy]
60
+ python_version = "3.11"
61
+ strict = true
@@ -0,0 +1,9 @@
1
+ # Copyright (c) 2026 Gregory R. Warnes
2
+ # SPDX-License-Identifier: MIT
3
+ """linkedown — Bidirectional Markdown ↔ LinkedIn Unicode converter."""
4
+
5
+ from .li_to_md import linkedin_to_md
6
+ from .md_to_li import md_to_linkedin
7
+
8
+ __all__ = ["md_to_linkedin", "linkedin_to_md"]
9
+ __version__ = "0.1.0"
@@ -0,0 +1,167 @@
1
+ """Command-line interface for linkedown.
2
+
3
+ Provides two entry points:
4
+ md2li — Markdown → LinkedIn Unicode
5
+ li2md — LinkedIn Unicode → Markdown
6
+
7
+ Both commands share the same input/output plumbing:
8
+
9
+ <cmd> FILE Read from file, write to stdout
10
+ <cmd> - Read from stdin, write to stdout
11
+ <cmd> FILE -o OUT Read from file, write to OUT
12
+ <cmd> FILE --copy Read from file, copy to clipboard
13
+ cat f | <cmd> Stdin → stdout (piped)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ import click
23
+ from rich.console import Console
24
+
25
+ from . import __version__, linkedin_to_md, md_to_linkedin
26
+
27
+ try:
28
+ import pyperclip as _pyperclip
29
+ except ImportError:
30
+ _pyperclip = None # type: ignore[assignment]
31
+
32
+ console = Console()
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Shared I/O helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def _read(input_file: Optional[Path]) -> str:
40
+ if input_file is None or str(input_file) == "-":
41
+ if input_file is None and sys.stdin.isatty():
42
+ raise click.UsageError(
43
+ "No input file specified and stdin is not being piped. "
44
+ "Pass a filename or pipe input via stdin."
45
+ )
46
+ return sys.stdin.read()
47
+ if not input_file.exists():
48
+ raise click.FileError(str(input_file), hint="File not found")
49
+ if not input_file.is_file():
50
+ raise click.FileError(str(input_file), hint="Path is not a file")
51
+ return input_file.read_text(encoding="utf-8")
52
+
53
+
54
+ def _write(text: str, output_file: Optional[Path], copy: bool, quiet: bool) -> None:
55
+ if copy:
56
+ if _pyperclip is None:
57
+ raise click.ClickException(
58
+ "Clipboard support requires the 'pyperclip' package: "
59
+ "pip install 'linkedown[clipboard]'"
60
+ )
61
+ _pyperclip.copy(text)
62
+ if not quiet:
63
+ console.print("[green]✓[/green] Copied to clipboard.")
64
+ return
65
+
66
+ if output_file:
67
+ output_file.parent.mkdir(parents=True, exist_ok=True)
68
+ output_file.write_text(text, encoding="utf-8")
69
+ if not quiet:
70
+ console.print(f"[green]✓[/green] Written to {output_file}")
71
+ return
72
+
73
+ sys.stdout.write(text)
74
+ if not text.endswith("\n"):
75
+ sys.stdout.write("\n")
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Shared Click options
80
+ # ---------------------------------------------------------------------------
81
+
82
+ _INPUT_ARG = click.argument(
83
+ "input_file",
84
+ type=click.Path(exists=False, path_type=Path),
85
+ required=False,
86
+ )
87
+ _OUTPUT_OPT = click.option(
88
+ "-o", "--output", "output_file",
89
+ type=click.Path(path_type=Path),
90
+ help="Write output to FILE instead of stdout.",
91
+ )
92
+ _COPY_OPT = click.option(
93
+ "-c", "--copy", "copy_to_clipboard",
94
+ is_flag=True,
95
+ help="Copy output to clipboard (requires pyperclip).",
96
+ )
97
+ _QUIET_OPT = click.option(
98
+ "-q", "--quiet",
99
+ is_flag=True,
100
+ help="Suppress informational messages.",
101
+ )
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # md2li command
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ @click.command("md2li")
110
+ @_INPUT_ARG
111
+ @_OUTPUT_OPT
112
+ @_COPY_OPT
113
+ @_QUIET_OPT
114
+ @click.version_option(version=__version__, prog_name="md2li")
115
+ def md2li_main(
116
+ input_file: Optional[Path],
117
+ output_file: Optional[Path],
118
+ copy_to_clipboard: bool,
119
+ quiet: bool,
120
+ ) -> None:
121
+ """Convert Markdown to LinkedIn Unicode-formatted text.
122
+
123
+ \b
124
+ Examples:
125
+ md2li post.md
126
+ md2li post.md -o linkedin.txt
127
+ md2li post.md --copy
128
+ cat post.md | md2li
129
+ """
130
+ text = _read(input_file)
131
+ result = md_to_linkedin(text)
132
+ _write(result, output_file, copy_to_clipboard, quiet)
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # li2md command
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ @click.command("li2md")
141
+ @_INPUT_ARG
142
+ @_OUTPUT_OPT
143
+ @_COPY_OPT
144
+ @_QUIET_OPT
145
+ @click.version_option(version=__version__, prog_name="li2md")
146
+ def li2md_main(
147
+ input_file: Optional[Path],
148
+ output_file: Optional[Path],
149
+ copy_to_clipboard: bool,
150
+ quiet: bool,
151
+ ) -> None:
152
+ """Convert LinkedIn Unicode-formatted text to Markdown.
153
+
154
+ The conversion is heuristic: structure (headings, lists, code blocks)
155
+ is inferred from LinkedIn rendering conventions. Results are generally
156
+ clean for posts created with md2li or similar tools.
157
+
158
+ \b
159
+ Examples:
160
+ li2md post.txt
161
+ li2md post.txt -o post.md
162
+ li2md post.txt --copy
163
+ cat post.txt | li2md
164
+ """
165
+ text = _read(input_file)
166
+ result = linkedin_to_md(text)
167
+ _write(result, output_file, copy_to_clipboard, quiet)
@@ -0,0 +1,249 @@
1
+ """LinkedIn Unicode → Markdown converter.
2
+
3
+ This is an original addition to the linkedown package, contributed back to
4
+ the md-to-linkedin upstream project via PR. The reverse direction is
5
+ inherently heuristic: LinkedIn posts have no explicit structural metadata,
6
+ so headings, bold spans, and italic spans are inferred from context.
7
+
8
+ Heuristic rules applied (in order):
9
+ 1. Lines whose *entire visible content* is bold Unicode → ``# Heading``
10
+ (level 1 only; LinkedIn has no heading hierarchy).
11
+ 2. Bullet lines (``• …``) → ``- …``
12
+ 3. Blockquote lines (``│ …``) → ``> …``
13
+ 4. Code-block lines (``▸ …``) → fenced ``` block
14
+ 5. Thematic break line (``─…``) → ``---``
15
+ 6. Inline bold spans → ``**…**``
16
+ 7. Inline italic spans → ``*…*``
17
+ 8. Inline monospace spans → `` `…` ``
18
+ 9. Link references ``text (url)`` → ``[text](url)`` (URL heuristic)
19
+ 10. Image references ``[Image: alt] url`` → ``![alt](url)``
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from typing import Final
26
+
27
+ from .unicode_maps import _ALL_STYLED_TO_ASCII, char_style, strip_styling
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Structural patterns
31
+ # ---------------------------------------------------------------------------
32
+
33
+ _BULLET_RE: Final[re.Pattern[str]] = re.compile(r"^[•]\s+(.*)")
34
+ _QUOTE_RE: Final[re.Pattern[str]] = re.compile(r"^│\s?(.*)")
35
+ _CODE_LINE_RE: Final[re.Pattern[str]] = re.compile(r"^▸\s?(.*)")
36
+ _HRULE_RE: Final[re.Pattern[str]] = re.compile(r"^─{3,}\s*$")
37
+ _IMAGE_REF_RE: Final[re.Pattern[str]] = re.compile(r"^\[Image:\s*(.*?)\]\s+(\S+)\s*$")
38
+ # Link: "some text (https://example.com)" — only treat URLs in parens as links
39
+ _LINK_INLINE_RE: Final[re.Pattern[str]] = re.compile(
40
+ r"(.*?)\s+\((https?://[^\s)]+)\)"
41
+ )
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Inline span reconstruction
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ def _spans_from_line(line: str) -> str:
49
+ """Convert a line of possibly-styled Unicode text to Markdown inline syntax.
50
+
51
+ Segments of consecutive characters sharing the same style are wrapped in
52
+ the appropriate Markdown delimiter. Plain characters are emitted as-is.
53
+
54
+ Style → delimiter mapping:
55
+ - bold → **…**
56
+ - italic → *…*
57
+ - monospace → `…`
58
+ - bold_italic → ***…*** (best approximation)
59
+ """
60
+ if not line:
61
+ return line
62
+
63
+ # Build list of (char, style_or_None)
64
+ segments: list[tuple[str, str | None]] = [
65
+ (_ALL_STYLED_TO_ASCII.get(c, (c, None))[0], char_style(c)) for c in line
66
+ ]
67
+
68
+ # Maximum number of plain (neutral) characters allowed as a bridge between
69
+ # two same-style spans. Keeps " " and ", " inside a span while leaving
70
+ # longer plain phrases (e.g. plain brand names like "oboe-mcp") outside.
71
+ _MAX_BRIDGE = 4
72
+
73
+ result: list[str] = []
74
+ i = 0
75
+ while i < len(segments):
76
+ ascii_ch, style = segments[i]
77
+ if style is None:
78
+ result.append(ascii_ch)
79
+ i += 1
80
+ else:
81
+ # Collect run of the same style, bridging short neutral gaps.
82
+ j = i
83
+ run_chars: list[str] = []
84
+ while j < len(segments):
85
+ ch, st = segments[j]
86
+ if st == style:
87
+ run_chars.append(ch)
88
+ j += 1
89
+ elif st is None:
90
+ # Peek ahead: collect the neutral gap, then check what follows.
91
+ gap: list[str] = []
92
+ k = j
93
+ while k < len(segments) and segments[k][1] is None:
94
+ gap.append(segments[k][0])
95
+ k += 1
96
+ # Bridge only if the gap is short and more same-style follows.
97
+ if (
98
+ k < len(segments)
99
+ and segments[k][1] == style
100
+ and len(gap) <= _MAX_BRIDGE
101
+ ):
102
+ run_chars.extend(gap)
103
+ j = k
104
+ else:
105
+ break
106
+ else:
107
+ break # Different style — end run.
108
+ run_text = "".join(run_chars)
109
+ delim = {
110
+ "bold": "**",
111
+ "italic": "*",
112
+ "monospace": "`",
113
+ "bold_italic": "***",
114
+ }.get(style, "")
115
+ result.append(f"{delim}{run_text}{delim}")
116
+ i = j
117
+
118
+ return "".join(result)
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Line-level checks
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ def _is_all_bold(line: str) -> bool:
127
+ """Return True if every alphanumeric character in *line* is bold Unicode."""
128
+ has_alpha = False
129
+ for c in line:
130
+ if c.isspace() or not c.isascii() and c not in _ALL_STYLED_TO_ASCII:
131
+ # structural punctuation — ignore
132
+ continue
133
+ style = char_style(c)
134
+ if style is None and (c.isalpha() or c.isdigit()):
135
+ return False # plain alphanumeric → not a heading
136
+ if style == "bold":
137
+ has_alpha = True
138
+ elif style is not None:
139
+ return False # mixed style → not a heading
140
+ return has_alpha
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Block-level reconstruction helpers
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ def _flush_code(code_lines: list[str]) -> str:
149
+ """Wrap accumulated code lines in a fenced block."""
150
+ return "```\n" + "\n".join(code_lines) + "\n```\n"
151
+
152
+
153
+ def _apply_link_heuristic(text: str) -> str:
154
+ """Convert 'label (url)' → '[label](url)' for HTTP/HTTPS URLs."""
155
+ return _LINK_INLINE_RE.sub(r"[\1](\2)", text)
156
+
157
+
158
+ def _apply_image_heuristic(text: str) -> str:
159
+ """Convert '[Image: alt] url' → '![alt](url)'."""
160
+ return _IMAGE_REF_RE.sub(r"![\1](\2)", text)
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Public API
165
+ # ---------------------------------------------------------------------------
166
+
167
+
168
+ def linkedin_to_md(text: str) -> str:
169
+ """Convert LinkedIn Unicode-formatted text to Markdown.
170
+
171
+ This conversion is heuristic. Structure (headings, lists, code blocks,
172
+ blockquotes) is inferred from LinkedIn rendering conventions used by
173
+ md-to-linkedin and similar tools. Inline bold/italic/monospace spans are
174
+ reconstructed from Unicode block membership.
175
+
176
+ Args:
177
+ text: LinkedIn post text, potentially containing Unicode styled characters.
178
+
179
+ Returns:
180
+ Approximate Markdown equivalent of the input.
181
+ """
182
+ lines = text.splitlines()
183
+ output: list[str] = []
184
+ code_buffer: list[str] = []
185
+ in_code_block = False
186
+
187
+ for raw_line in lines:
188
+ # --- Code block lines (▸) ---
189
+ code_match = _CODE_LINE_RE.match(raw_line)
190
+ if code_match:
191
+ in_code_block = True
192
+ code_buffer.append(code_match.group(1))
193
+ continue
194
+
195
+ # Flush code block when we leave code territory
196
+ if in_code_block:
197
+ output.append(_flush_code(code_buffer))
198
+ code_buffer = []
199
+ in_code_block = False
200
+
201
+ # --- Thematic break ---
202
+ if _HRULE_RE.match(raw_line):
203
+ output.append("---\n")
204
+ continue
205
+
206
+ # --- Image reference ---
207
+ if _IMAGE_REF_RE.match(raw_line):
208
+ output.append(_apply_image_heuristic(raw_line) + "\n")
209
+ continue
210
+
211
+ # --- Blockquote ---
212
+ quote_match = _QUOTE_RE.match(raw_line)
213
+ if quote_match:
214
+ inner = _spans_from_line(quote_match.group(1))
215
+ output.append(f"> {inner}\n")
216
+ continue
217
+
218
+ # --- Bullet list ---
219
+ bullet_match = _BULLET_RE.match(raw_line)
220
+ if bullet_match:
221
+ inner = _spans_from_line(bullet_match.group(1))
222
+ inner = _apply_link_heuristic(inner)
223
+ output.append(f"- {inner}\n")
224
+ continue
225
+
226
+ # --- Empty line ---
227
+ if not raw_line.strip():
228
+ output.append("\n")
229
+ continue
230
+
231
+ # --- Heading heuristic: entirely bold non-empty line ---
232
+ if _is_all_bold(raw_line):
233
+ plain = strip_styling(raw_line)
234
+ output.append(f"# {plain}\n")
235
+ continue
236
+
237
+ # --- Regular paragraph line ---
238
+ converted = _spans_from_line(raw_line)
239
+ converted = _apply_link_heuristic(converted)
240
+ converted = _apply_image_heuristic(converted)
241
+ output.append(converted + "\n")
242
+
243
+ # Flush any trailing code block
244
+ if in_code_block and code_buffer:
245
+ output.append(_flush_code(code_buffer))
246
+
247
+ # Collapse runs of more than two blank lines
248
+ result = re.sub(r"\n{3,}", "\n\n", "".join(output))
249
+ return result.lstrip("\n")
@@ -0,0 +1,166 @@
1
+ """Markdown → LinkedIn Unicode converter.
2
+
3
+ Core conversion logic is derived from md-to-linkedin by Scott Henning
4
+ (https://github.com/shenning00/md-to-linkedin), MIT licence, with minor
5
+ adaptations for integration into linkedown.
6
+
7
+ Changes vs. upstream:
8
+ - Renderer and converter inlined here; no separate renderer.py
9
+ - ``convert()`` accepts the same ``options`` dict as upstream for compatibility
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from typing import Any, Final, Optional
16
+
17
+ import mistune
18
+ from mistune.core import BlockState
19
+
20
+ from .unicode_maps import to_bold, to_italic, to_monospace
21
+
22
+ MAX_CONSECUTIVE_BLANK_LINES: Final[int] = 2
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Renderer
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ class _LinkedInRenderer(mistune.BaseRenderer):
31
+ """mistune renderer that converts Markdown tokens to LinkedIn Unicode text."""
32
+
33
+ NAME = "linkedin"
34
+
35
+ def __init__(self) -> None:
36
+ super().__init__()
37
+
38
+ def render_token(self, token: dict[str, Any], state: BlockState) -> str:
39
+ func = self._get_method(token["type"])
40
+ attrs = token.get("attrs")
41
+ if "raw" in token:
42
+ text = token["raw"]
43
+ elif "children" in token:
44
+ text = self.render_tokens(token["children"], state)
45
+ else:
46
+ return func(**attrs) if attrs else func()
47
+ return func(text, **attrs) if attrs else func(text)
48
+
49
+ def blank_line(self) -> str:
50
+ return ""
51
+
52
+ def block_text(self, text: str) -> str:
53
+ return text
54
+
55
+ def text(self, text: str) -> str:
56
+ return text
57
+
58
+ def heading(self, text: str, level: int, **_: Any) -> str:
59
+ return f"{to_bold(text)}\n\n"
60
+
61
+ def paragraph(self, text: str) -> str:
62
+ return f"{text}\n\n"
63
+
64
+ def emphasis(self, text: str) -> str:
65
+ return to_italic(text)
66
+
67
+ def strong(self, text: str) -> str:
68
+ return to_bold(text)
69
+
70
+ def codespan(self, text: str) -> str:
71
+ return to_monospace(text)
72
+
73
+ def block_code(self, code: str, info: Optional[str] = None) -> str:
74
+ code = code.rstrip("\n")
75
+ lines = [f"▸ {line}" for line in code.split("\n")]
76
+ return "\n".join(lines) + "\n\n"
77
+
78
+ def list(self, body: str, ordered: bool, **_: Any) -> str:
79
+ return body + "\n"
80
+
81
+ def list_item(self, text: str, **_: Any) -> str:
82
+ return f"• {text.rstrip()}\n"
83
+
84
+ def link(self, text: str, url: str, title: Optional[str] = None) -> str:
85
+ return f"{text} ({url})"
86
+
87
+ def image(self, alt: str, url: str, title: Optional[str] = None) -> str:
88
+ return f"[Image: {alt}] {url}"
89
+
90
+ def block_quote(self, text: str) -> str:
91
+ text = text.rstrip("\n")
92
+ lines = [f"│ {line}" for line in text.split("\n")]
93
+ return "\n".join(lines) + "\n\n"
94
+
95
+ def thematic_break(self) -> str:
96
+ return "─────────────\n\n"
97
+
98
+ def newline(self) -> str:
99
+ return "\n"
100
+
101
+ def linebreak(self) -> str:
102
+ return "\n"
103
+
104
+ def softbreak(self) -> str:
105
+ return " "
106
+
107
+ def inline_html(self, html: str) -> str:
108
+ return html
109
+
110
+ def raw_html(self, html: str) -> str:
111
+ return html
112
+
113
+ def strikethrough(self, text: str) -> str:
114
+ return text
115
+
116
+ def table(self, text: str) -> str:
117
+ return text + "\n"
118
+
119
+ def table_head(self, text: str) -> str:
120
+ return text
121
+
122
+ def table_body(self, text: str) -> str:
123
+ return text
124
+
125
+ def table_row(self, text: str) -> str:
126
+ return text + "\n"
127
+
128
+ def table_cell(self, text: str, **_: Any) -> str:
129
+ return f"| {text} "
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Public API
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ def md_to_linkedin(markdown_text: str, options: Optional[dict[str, Any]] = None) -> str:
138
+ """Convert Markdown text to LinkedIn-compatible Unicode-formatted text.
139
+
140
+ Args:
141
+ markdown_text: Standard Markdown input.
142
+ options: Reserved for future configuration (ignored currently).
143
+
144
+ Returns:
145
+ LinkedIn-compatible plain-text with Unicode bold/italic/monospace.
146
+ """
147
+ renderer = _LinkedInRenderer()
148
+ plugins = []
149
+ try:
150
+ plugins = ["strikethrough", "table"]
151
+ except Exception:
152
+ pass
153
+
154
+ markdown = mistune.create_markdown(renderer=renderer, plugins=plugins)
155
+ rendered = markdown(markdown_text)
156
+ if not isinstance(rendered, str):
157
+ raise TypeError(f"Expected str from renderer, got {type(rendered)}")
158
+ return _post_process(rendered)
159
+
160
+
161
+ def _post_process(text: str) -> str:
162
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
163
+ lines = text.split("\n")
164
+ text = "\n".join(line.rstrip() for line in lines)
165
+ text = re.sub(r"\n{3,}", "\n\n", text)
166
+ return text.rstrip("\n") + "\n"
File without changes
@@ -0,0 +1,70 @@
1
+ # Copyright (c) 2026 Gregory R. Warnes
2
+ # SPDX-License-Identifier: MIT
3
+ """linkedown MCP server.
4
+
5
+ Exposes two tools for use by coding agents:
6
+ md_to_linkedin — Convert Markdown text to LinkedIn Unicode-formatted text
7
+ linkedin_to_md — Convert LinkedIn Unicode text back to Markdown
8
+
9
+ Run as a standalone server:
10
+ linkedown-mcp
11
+ uvx linkedown linkedown-mcp
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from mcp.server.fastmcp import FastMCP
17
+
18
+ from . import linkedin_to_md, md_to_linkedin
19
+
20
+ mcp = FastMCP(
21
+ "linkedown",
22
+ instructions=(
23
+ "Tools for converting between Markdown and LinkedIn Unicode formatting. "
24
+ "Use md_to_linkedin when preparing a post for LinkedIn. "
25
+ "Use linkedin_to_md to convert existing LinkedIn text back to Markdown."
26
+ ),
27
+ )
28
+
29
+
30
+ @mcp.tool()
31
+ def md_to_linkedin_tool(markdown_text: str) -> str:
32
+ """Convert Markdown text to LinkedIn-compatible Unicode-formatted text.
33
+
34
+ LinkedIn does not render Markdown, but does display Unicode styled characters
35
+ correctly. This tool converts **bold**, *italic*, `code`, headings, lists,
36
+ blockquotes, and code blocks to their LinkedIn-compatible equivalents.
37
+
38
+ Args:
39
+ markdown_text: Standard Markdown-formatted text.
40
+
41
+ Returns:
42
+ LinkedIn-compatible plain text with Unicode bold/italic/monospace styling.
43
+ """
44
+ return md_to_linkedin(markdown_text)
45
+
46
+
47
+ @mcp.tool()
48
+ def linkedin_to_md_tool(linkedin_text: str) -> str:
49
+ """Convert LinkedIn Unicode-formatted text back to Markdown.
50
+
51
+ Reconstructs Markdown structure (headings, lists, code blocks, blockquotes)
52
+ and inline formatting (bold, italic, monospace) from LinkedIn Unicode characters.
53
+ The conversion is heuristic: results are best for posts originally created
54
+ from Markdown.
55
+
56
+ Args:
57
+ linkedin_text: Text copied from a LinkedIn post, containing Unicode styling.
58
+
59
+ Returns:
60
+ Approximate Markdown equivalent of the input.
61
+ """
62
+ return linkedin_to_md(linkedin_text)
63
+
64
+
65
+ def main() -> None:
66
+ mcp.run()
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -0,0 +1,185 @@
1
+ """Unicode character mapping tables for LinkedIn ↔ Markdown text style transformations.
2
+
3
+ The forward (Markdown → LinkedIn) character maps and conversion functions in this
4
+ module are derived from md-to-linkedin by Scott Henning
5
+ (https://github.com/shenning00/md-to-linkedin), licensed MIT.
6
+
7
+ The inverse (LinkedIn → Markdown) maps and ``from_*`` functions are original additions
8
+ contributed back to that project via upstream PR.
9
+
10
+ Unicode ranges used:
11
+ - Bold: U+1D5D4–U+1D607 (A–Z, a–z), U+1D7EC–U+1D7F5 (0–9) [Sans-Serif Bold]
12
+ - Italic: U+1D434–U+1D467 (A–Z, a–z)
13
+ - Bold-Italic: U+1D468–U+1D49B (A–Z, a–z)
14
+ - Monospace: U+1D670–U+1D6A3 (A–Z, a–z), U+1D7F6–U+1D7FF (0–9)
15
+
16
+ Unsupported characters (punctuation, whitespace, etc.) pass through unchanged in
17
+ both directions.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Final
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Forward maps: ASCII → Unicode styled
26
+ # ---------------------------------------------------------------------------
27
+
28
+ # Bold (Mathematical Sans-Serif Bold)
29
+ # A-Z → U+1D5D4-U+1D5ED
30
+ BOLD_UPPER: Final[dict[str, str]] = {
31
+ chr(ord("A") + i): chr(0x1D5D4 + i) for i in range(26)
32
+ }
33
+ # a-z → U+1D5EE-U+1D607
34
+ BOLD_LOWER: Final[dict[str, str]] = {
35
+ chr(ord("a") + i): chr(0x1D5EE + i) for i in range(26)
36
+ }
37
+ # 0-9 → U+1D7EC-U+1D7F5
38
+ BOLD_DIGITS: Final[dict[str, str]] = {
39
+ chr(ord("0") + i): chr(0x1D7EC + i) for i in range(10)
40
+ }
41
+
42
+ # Italic (Mathematical Italic)
43
+ # A-Z → U+1D434-U+1D44D
44
+ # Note: H (capital) is contiguous; h (lowercase) has a gap at U+210E
45
+ ITALIC_UPPER: Final[dict[str, str]] = {
46
+ chr(ord("A") + i): chr(0x1D434 + i) for i in range(26)
47
+ }
48
+ # a-z → U+1D44E-U+1D467 (italic 'h' is special: U+210E)
49
+ ITALIC_LOWER: Final[dict[str, str]] = {
50
+ chr(ord("a") + i): chr(0x1D44E + i) if i != 7 else chr(0x210E) for i in range(26)
51
+ }
52
+
53
+ # Sans-Serif Italic (used by some external LinkedIn formatting tools)
54
+ # A-Z → U+1D608-U+1D621
55
+ _SANS_SERIF_ITALIC_UPPER: Final[dict[str, str]] = {
56
+ chr(ord("A") + i): chr(0x1D608 + i) for i in range(26)
57
+ }
58
+ # a-z → U+1D622-U+1D63B
59
+ _SANS_SERIF_ITALIC_LOWER: Final[dict[str, str]] = {
60
+ chr(ord("a") + i): chr(0x1D622 + i) for i in range(26)
61
+ }
62
+
63
+ # Bold-Italic (Mathematical Bold Italic)
64
+ # A-Z → U+1D468-U+1D481
65
+ BOLD_ITALIC_UPPER: Final[dict[str, str]] = {
66
+ chr(ord("A") + i): chr(0x1D468 + i) for i in range(26)
67
+ }
68
+ # a-z → U+1D482-U+1D49B
69
+ BOLD_ITALIC_LOWER: Final[dict[str, str]] = {
70
+ chr(ord("a") + i): chr(0x1D482 + i) for i in range(26)
71
+ }
72
+
73
+ # Monospace (Mathematical Monospace)
74
+ # A-Z → U+1D670-U+1D689
75
+ MONOSPACE_UPPER: Final[dict[str, str]] = {
76
+ chr(ord("A") + i): chr(0x1D670 + i) for i in range(26)
77
+ }
78
+ # a-z → U+1D68A-U+1D6A3
79
+ MONOSPACE_LOWER: Final[dict[str, str]] = {
80
+ chr(ord("a") + i): chr(0x1D68A + i) for i in range(26)
81
+ }
82
+ # 0-9 → U+1D7F6-U+1D7FF
83
+ MONOSPACE_DIGITS: Final[dict[str, str]] = {
84
+ chr(ord("0") + i): chr(0x1D7F6 + i) for i in range(10)
85
+ }
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Inverse maps: Unicode styled → ASCII (derived automatically)
89
+ # ---------------------------------------------------------------------------
90
+
91
+ _BOLD_TO_ASCII: Final[dict[str, str]] = {
92
+ **{v: k for k, v in BOLD_UPPER.items()},
93
+ **{v: k for k, v in BOLD_LOWER.items()},
94
+ **{v: k for k, v in BOLD_DIGITS.items()},
95
+ }
96
+ _ITALIC_TO_ASCII: Final[dict[str, str]] = {
97
+ **{v: k for k, v in ITALIC_UPPER.items()},
98
+ **{v: k for k, v in ITALIC_LOWER.items()},
99
+ # sans-serif italic (produced by some external tools) also maps to italic
100
+ **{v: k for k, v in _SANS_SERIF_ITALIC_UPPER.items()},
101
+ **{v: k for k, v in _SANS_SERIF_ITALIC_LOWER.items()},
102
+ }
103
+ _BOLD_ITALIC_TO_ASCII: Final[dict[str, str]] = {
104
+ **{v: k for k, v in BOLD_ITALIC_UPPER.items()},
105
+ **{v: k for k, v in BOLD_ITALIC_LOWER.items()},
106
+ }
107
+ _MONOSPACE_TO_ASCII: Final[dict[str, str]] = {
108
+ **{v: k for k, v in MONOSPACE_UPPER.items()},
109
+ **{v: k for k, v in MONOSPACE_LOWER.items()},
110
+ **{v: k for k, v in MONOSPACE_DIGITS.items()},
111
+ }
112
+
113
+ # Combined reverse map: any styled char → (ascii_char, style_name)
114
+ # Each source dict is inverted independently to avoid key collisions when merging
115
+ # ASCII→Unicode dicts (which share ASCII keys).
116
+ _ALL_STYLED_TO_ASCII: Final[dict[str, tuple[str, str]]] = {
117
+ # Bold
118
+ **{v: (k, "bold") for k, v in BOLD_UPPER.items()},
119
+ **{v: (k, "bold") for k, v in BOLD_LOWER.items()},
120
+ **{v: (k, "bold") for k, v in BOLD_DIGITS.items()},
121
+ # Italic (mathematical)
122
+ **{v: (k, "italic") for k, v in ITALIC_UPPER.items()},
123
+ **{v: (k, "italic") for k, v in ITALIC_LOWER.items()},
124
+ # Italic (sans-serif, used by some external tools)
125
+ **{v: (k, "italic") for k, v in _SANS_SERIF_ITALIC_UPPER.items()},
126
+ **{v: (k, "italic") for k, v in _SANS_SERIF_ITALIC_LOWER.items()},
127
+ # Bold-Italic
128
+ **{v: (k, "bold_italic") for k, v in BOLD_ITALIC_UPPER.items()},
129
+ **{v: (k, "bold_italic") for k, v in BOLD_ITALIC_LOWER.items()},
130
+ # Monospace
131
+ **{v: (k, "monospace") for k, v in MONOSPACE_UPPER.items()},
132
+ **{v: (k, "monospace") for k, v in MONOSPACE_LOWER.items()},
133
+ **{v: (k, "monospace") for k, v in MONOSPACE_DIGITS.items()},
134
+ }
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Forward conversion helpers
138
+ # ---------------------------------------------------------------------------
139
+
140
+
141
+ def to_bold(text: str) -> str:
142
+ """Convert ASCII text to Unicode Mathematical Sans-Serif Bold."""
143
+ return "".join(
144
+ BOLD_UPPER.get(c) or BOLD_LOWER.get(c) or BOLD_DIGITS.get(c) or c
145
+ for c in text
146
+ )
147
+
148
+
149
+ def to_italic(text: str) -> str:
150
+ """Convert ASCII text to Unicode Mathematical Italic (letters only)."""
151
+ return "".join(ITALIC_UPPER.get(c) or ITALIC_LOWER.get(c) or c for c in text)
152
+
153
+
154
+ def to_bold_italic(text: str) -> str:
155
+ """Convert ASCII text to Unicode Mathematical Bold Italic."""
156
+ return "".join(
157
+ BOLD_ITALIC_UPPER.get(c) or BOLD_ITALIC_LOWER.get(c) or c for c in text
158
+ )
159
+
160
+
161
+ def to_monospace(text: str) -> str:
162
+ """Convert ASCII text to Unicode Mathematical Monospace."""
163
+ return "".join(
164
+ MONOSPACE_UPPER.get(c) or MONOSPACE_LOWER.get(c) or MONOSPACE_DIGITS.get(c) or c
165
+ for c in text
166
+ )
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Reverse conversion helpers
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ def strip_styling(text: str) -> str:
175
+ """Strip all LinkedIn Unicode styling, returning plain ASCII text.
176
+
177
+ Any character not in a styled Unicode block is passed through unchanged.
178
+ """
179
+ return "".join(_ALL_STYLED_TO_ASCII.get(c, (c, ""))[0] for c in text)
180
+
181
+
182
+ def char_style(c: str) -> str | None:
183
+ """Return the style name for a styled Unicode character, or None if plain."""
184
+ entry = _ALL_STYLED_TO_ASCII.get(c)
185
+ return entry[1] if entry else None