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.
- linkedown-0.1.0/.gitignore +22 -0
- linkedown-0.1.0/LICENSE +21 -0
- linkedown-0.1.0/PKG-INFO +101 -0
- linkedown-0.1.0/README.md +64 -0
- linkedown-0.1.0/pyproject.toml +61 -0
- linkedown-0.1.0/src/linkedown/__init__.py +9 -0
- linkedown-0.1.0/src/linkedown/cli.py +167 -0
- linkedown-0.1.0/src/linkedown/li_to_md.py +249 -0
- linkedown-0.1.0/src/linkedown/md_to_li.py +166 -0
- linkedown-0.1.0/src/linkedown/py.typed +0 -0
- linkedown-0.1.0/src/linkedown/server.py +70 -0
- linkedown-0.1.0/src/linkedown/unicode_maps.py +185 -0
|
@@ -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/
|
linkedown-0.1.0/LICENSE
ADDED
|
@@ -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.
|
linkedown-0.1.0/PKG-INFO
ADDED
|
@@ -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`` → ````
|
|
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' → ''."""
|
|
160
|
+
return _IMAGE_REF_RE.sub(r"", 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
|