marktip 0.4.0__tar.gz → 0.6.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.
- {marktip-0.4.0 → marktip-0.6.0}/CMakeLists.txt +1 -1
- marktip-0.6.0/PKG-INFO +290 -0
- marktip-0.6.0/README.md +262 -0
- {marktip-0.4.0 → marktip-0.6.0}/pyproject.toml +1 -1
- marktip-0.6.0/src/ast.cpp +1103 -0
- marktip-0.6.0/src/ast.h +116 -0
- {marktip-0.4.0 → marktip-0.6.0}/src/errors.h +4 -1
- {marktip-0.4.0 → marktip-0.6.0}/src/marktip.cpp +47 -7
- {marktip-0.4.0 → marktip-0.6.0}/src/parser.cpp +11 -1
- marktip-0.6.0/src/parser.h +13 -0
- {marktip-0.4.0 → marktip-0.6.0}/src/serializer.cpp +37 -16
- {marktip-0.4.0 → marktip-0.6.0}/tests/test_errors.py +15 -0
- {marktip-0.4.0 → marktip-0.6.0}/tests/test_roundtrip.py +18 -1
- {marktip-0.4.0 → marktip-0.6.0}/tests/test_serialize.py +87 -0
- marktip-0.6.0/tests/test_strict.py +404 -0
- marktip-0.6.0/tests/test_uri_policy.py +430 -0
- {marktip-0.4.0 → marktip-0.6.0}/tests/test_validate.py +3 -0
- {marktip-0.4.0 → marktip-0.6.0}/third_party/md4c/upstream_metadata/README.md +8 -0
- marktip-0.4.0/PKG-INFO +0 -179
- marktip-0.4.0/README.md +0 -151
- marktip-0.4.0/src/ast.cpp +0 -408
- marktip-0.4.0/src/ast.h +0 -78
- marktip-0.4.0/src/parser.h +0 -11
- {marktip-0.4.0 → marktip-0.6.0}/.github/workflows/build-distributions.yml +0 -0
- {marktip-0.4.0 → marktip-0.6.0}/.gitignore +0 -0
- {marktip-0.4.0 → marktip-0.6.0}/LICENSE +0 -0
- {marktip-0.4.0 → marktip-0.6.0}/python/marktip/__init__.py +0 -0
- {marktip-0.4.0 → marktip-0.6.0}/scripts/benchmark.py +0 -0
- {marktip-0.4.0 → marktip-0.6.0}/src/serializer.h +0 -0
- {marktip-0.4.0 → marktip-0.6.0}/tests/test_document.py +0 -0
- {marktip-0.4.0 → marktip-0.6.0}/tests/test_parse.py +0 -0
- {marktip-0.4.0 → marktip-0.6.0}/third_party/md4c/LICENSE.md +0 -0
- {marktip-0.4.0 → marktip-0.6.0}/third_party/md4c/src/entity.c +0 -0
- {marktip-0.4.0 → marktip-0.6.0}/third_party/md4c/src/entity.h +0 -0
- {marktip-0.4.0 → marktip-0.6.0}/third_party/md4c/src/md4c.c +0 -0
- {marktip-0.4.0 → marktip-0.6.0}/third_party/md4c/src/md4c.h +0 -0
marktip-0.6.0/PKG-INFO
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: marktip
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Fast C++/MD4C Markdown parser and canonical Markdown serializer for Tiptap-style JSON
|
|
5
|
+
Keywords: markdown,tiptap,md4c,parser
|
|
6
|
+
Author: Saneaven (Minjae Kyung)
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
License-File: third_party/md4c/LICENSE.md
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: C++
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# marktip
|
|
30
|
+
|
|
31
|
+
Fast C++/MD4C Markdown conversion for Tiptap-style JSON.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python -m pip install marktip
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Release wheels are built for common CPython versions on Linux, macOS, and Windows.
|
|
40
|
+
If a wheel is not available for a platform, pip can build from the source distribution with a C++17 compiler and standard Python build tooling.
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import marktip as tm
|
|
46
|
+
|
|
47
|
+
doc = tm.from_markdown("# Hello")
|
|
48
|
+
ast = doc.to_dict()
|
|
49
|
+
markdown = doc.to_markdown()
|
|
50
|
+
|
|
51
|
+
doc = tm.from_dict(ast)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`from_markdown` follows GFM/CommonMark by default.
|
|
55
|
+
Pass `cjk_friendly=True` to relax the emphasis and strikethrough rules so delimiters next to CJK text still open and close (e.g. `**볼드**은` parses as bold), a non-standard extension that is off by default:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
doc = tm.from_markdown("**볼드**은 강조", cjk_friendly=True)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Strikethrough follows the GFM reference implementations (cmark-gfm/micromark): intra-word `~~` strikes (`a~~b~~c`), including next to CJK letters (`~~삭제~~은`).
|
|
62
|
+
`cjk_friendly` still matters for `*`/`_`, and for `~` in punctuation-adjacent cases such as `~~"인용"~~라고`.
|
|
63
|
+
|
|
64
|
+
Pass `html=False` to parse raw HTML as literal text instead of `htmlBlock`/`htmlInline` nodes.
|
|
65
|
+
Content is preserved (the serializer escapes it), and `<br>` inside table cells still maps to `hardBreak` so marktip's own table output round-trips:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
doc = tm.from_markdown("a <u>x</u> b", html=False)
|
|
69
|
+
# paragraph with the literal text "a <u>x</u> b"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
marktip targets GFM core syntax and canonical Markdown output rather than byte-identical source preservation.
|
|
73
|
+
|
|
74
|
+
### Restricting link and image URIs
|
|
75
|
+
|
|
76
|
+
`[click](javascript:alert(1))` is valid Markdown, so marktip converts it by default.
|
|
77
|
+
A consumer that *stores* the result and renders it later can opt into a scheme allowlist, enforced in C++ rather than as a second traversal in Python:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
doc = tm.from_dict(ast, link_schemes=("http", "https", "mailto"), image_schemes=("https",))
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`link_schemes` and `image_schemes` are separate because the asymmetry is the common case, and both default to `None`, which allows every scheme.
|
|
84
|
+
An empty tuple allows none.
|
|
85
|
+
Scheme comparison is case-insensitive, and entity references are resolved first, so neither `JavaScript:` nor `javascript:` slips past an `https`-only list.
|
|
86
|
+
|
|
87
|
+
`link_relative` and `image_relative` govern references that carry no scheme at all:
|
|
88
|
+
|
|
89
|
+
| | `/foo`, `#anchor`, `./x.png` | `//evil.com/x` |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `"allow"` (default) | passes | passes |
|
|
92
|
+
| `"path_only"` | passes | rejected |
|
|
93
|
+
| `"reject"` | rejected | rejected |
|
|
94
|
+
|
|
95
|
+
The two axes are independent: `link_relative="reject"` with no allowlist means "any scheme, but it must be absolute".
|
|
96
|
+
|
|
97
|
+
Both write boundaries take the same options, which matters when an editor submits an AST but agents and imports submit Markdown strings:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
doc = tm.from_markdown(untrusted, link_schemes=("https",), image_relative="reject")
|
|
101
|
+
# InvalidNodeError: link href scheme 'javascript' is not allowed
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Violations raise `InvalidNodeError` with `.field` set to `"href"` or `"src"`.
|
|
105
|
+
The options are keyword-only.
|
|
106
|
+
|
|
107
|
+
### Refusing lossy conversions
|
|
108
|
+
|
|
109
|
+
Node and mark *types* are closed, but `attrs` are not.
|
|
110
|
+
An attr the serializer never reads is dropped without a word, so a `colspan: 2` cell quietly becomes a plain one:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
tm.from_dict(ast).to_markdown() # '| x |\n| --- |' — the colspan is gone
|
|
114
|
+
tm.from_dict(ast, strict=True) # InvalidNodeError: attr 'colspan' cannot be
|
|
115
|
+
# expressed in markdown: GFM tables have no cell spanning
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`strict=True` refuses anything markdown would drop or alter, from the walk that is already happening, so a consumer does not have to re-traverse the tree in Python to find out whether the document survives intact.
|
|
119
|
+
It is off by default and keyword-only, and it is a `from_dict` option only — the parser emits nothing but attrs it understands, with values in range, so there is nothing on the `from_markdown` path for it to catch.
|
|
120
|
+
|
|
121
|
+
| Node | Accepted attrs |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| `heading` | `level`: 1–6 |
|
|
124
|
+
| `codeBlock` | `language`, `info`: single-line `str` or `None` |
|
|
125
|
+
| `bulletList`, `taskList` | `tight`: `bool` |
|
|
126
|
+
| `orderedList` | `start`: 0–999999999, `tight`: `bool` |
|
|
127
|
+
| `taskItem` | `checked`: `bool` |
|
|
128
|
+
| `table` | `colCount`: non-negative `int` |
|
|
129
|
+
| `tableHeader`, `tableCell` | `align`: `"left"`/`"center"`/`"right"`/`None`, `colspan`: `1`, `rowspan`: `1`, `colwidth`: `None` |
|
|
130
|
+
| `image` | `src`, `alt`, `title`: `str` or `None` |
|
|
131
|
+
| `htmlBlock`, `htmlInline` | `html`: `str` |
|
|
132
|
+
| `link` mark | `href`, `title`: `str` or `None` |
|
|
133
|
+
|
|
134
|
+
Every other type takes no attrs at all.
|
|
135
|
+
Under strict, `bool` and `int` stay distinct — `{"level": True}` is refused rather than read as `1`.
|
|
136
|
+
|
|
137
|
+
> **Changed in 0.6.0** — `None` on the attrs above that accept it now means the attr is unset, and the key is dropped on the way in rather than coerced to `""`.
|
|
138
|
+
> Two things move with it: an `image` whose `alt` is `None` *and* which carries content children now renders that child text, and `to_dict()` omits such a key instead of echoing `""`.
|
|
139
|
+
|
|
140
|
+
`None` is accepted exactly where Tiptap's own schema declares `default: null` — `codeBlock`'s `language`/`info`, `image`'s `src`/`alt`/`title`, and `link`'s `href`/`title`.
|
|
141
|
+
ProseMirror serializes every attr in the schema, defaults included, so `None` is what the editor submits for an attr nobody ever filled in, and an unset attr is accepted already; the markdown is the same either way.
|
|
142
|
+
There are two carve-outs.
|
|
143
|
+
`html` is marktip's own attr with no Tiptap counterpart, and the serializer reads it as the node's payload when it is present but empty, so a `None` there would drop content rather than leave an attr unset.
|
|
144
|
+
The rest — `level`, `start`, `tight`, `checked`, `colCount`, `colspan`, `rowspan` — are settings Tiptap gives a non-null default, so a `None` on one of those is a caller mistake and stays refused.
|
|
145
|
+
`align` and `colwidth` are unchanged: `None` was already a *value* there, not an unset marker.
|
|
146
|
+
|
|
147
|
+
`colspan`/`rowspan`/`colwidth` are *known* attrs whose only accepted values are the no-ops Tiptap's table extension puts on every cell.
|
|
148
|
+
Refusing the names outright would leave strict unusable for the documents it exists to check.
|
|
149
|
+
Tiptap's Link extension is the other direction: its default `target` and `rel` have no markdown form, so they are refused, which is the same rule that stops an `onclick` from vanishing silently.
|
|
150
|
+
|
|
151
|
+
Two attrs are accepted and then ignored, and they are the only two.
|
|
152
|
+
The serializer derives a table's column count from its rows, so `colCount` is never read, and it takes cell alignment from the header row only, so `align` on a body row is never read either.
|
|
153
|
+
Both are refused nowhere because `from_markdown` emits both: rejecting them would break re-parsing stored canonical Markdown on the read path, where a write-time refusal is much harder to notice.
|
|
154
|
+
|
|
155
|
+
## Errors
|
|
156
|
+
|
|
157
|
+
Every rejection marktip raises derives from `marktip.MarktipError`, so "the input is malformed" is a single `except` — no need to catch a bare `ValueError` wide enough to swallow an unrelated bug:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
try:
|
|
161
|
+
tm.from_dict(payload).to_markdown()
|
|
162
|
+
except tm.MarktipError as e:
|
|
163
|
+
return 422, {"code": e.code, "path": e.path, "detail": e.detail}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
Exception
|
|
168
|
+
└─ MarktipError .code, .path, .detail
|
|
169
|
+
├─ UnknownTypeError + .type, .kind — type outside the closed schema
|
|
170
|
+
├─ InvalidNodeError + .field — violates the node grammar
|
|
171
|
+
└─ ParseError — markdown could not be parsed
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`.path` is a breadcrumb into the input, e.g. `content[1].content[0].marks[0]`; it is `""` when the failure is at the root or has no location.
|
|
175
|
+
For `from_markdown` it locates the node in the parsed document, since the input itself is a string.
|
|
176
|
+
`.detail` is the same string as `str(e)`.
|
|
177
|
+
`.code` is a stable machine key:
|
|
178
|
+
|
|
179
|
+
| `.code` | Class | Raised when |
|
|
180
|
+
| --- | --- | --- |
|
|
181
|
+
| `unknown_node_type` | `UnknownTypeError` | node `type` is outside the closed schema |
|
|
182
|
+
| `unknown_mark_type` | `UnknownTypeError` | mark `type` is outside the closed schema |
|
|
183
|
+
| `missing_type` | `InvalidNodeError` | a node or mark has no `type` key |
|
|
184
|
+
| `invalid_root` | `InvalidNodeError` | the root node's type is not `doc` |
|
|
185
|
+
| `wrong_type` | `InvalidNodeError` | `attrs`/`content`/`marks`/a child has the wrong Python type |
|
|
186
|
+
| `max_depth` | `InvalidNodeError` | dict nesting exceeds 2048 |
|
|
187
|
+
| `disallowed_scheme` | `InvalidNodeError` | a link `href` / image `src` scheme is outside the allowlist |
|
|
188
|
+
| `disallowed_relative_url` | `InvalidNodeError` | a scheme-less `href`/`src` violates the relative policy |
|
|
189
|
+
| `invalid_uri_char` | `InvalidNodeError` | an `href`/`src` contains an ASCII control character |
|
|
190
|
+
| `unknown_attr` | `InvalidNodeError` | `strict`: the attr has no markdown form for that type |
|
|
191
|
+
| `invalid_attr_value` | `InvalidNodeError` | `strict`: the attr's value has the wrong type or is out of range |
|
|
192
|
+
| `unrepresentable` | `InvalidNodeError` | `strict`: the value is well-formed, but GFM cannot carry it |
|
|
193
|
+
| `markdown_max_depth` | `ParseError` | markdown nesting exceeds 2048 |
|
|
194
|
+
| `parse_failed` | `ParseError` | MD4C could not parse the input |
|
|
195
|
+
|
|
196
|
+
`from_markdown` still raises a plain `TypeError` when the argument is not `str`/`bytes` — that is a caller bug, not a malformed document.
|
|
197
|
+
So is a bad URI-policy option: a non-iterable `link_schemes` raises `TypeError`, and `link_schemes=("https://",)` or `link_relative="nope"` raises `ValueError`.
|
|
198
|
+
|
|
199
|
+
> **Changed in 0.4.0** — these were previously `ValueError` (`from_dict`) and `RuntimeError` (`from_markdown`).
|
|
200
|
+
> `MarktipError` derives from `Exception` directly, so `except ValueError` no longer catches them.
|
|
201
|
+
|
|
202
|
+
> **Changed in 0.5.0** — an ASCII control character in a link `href` or image `src` is now rejected outright, with or without the URI policy.
|
|
203
|
+
> A URI cannot carry one unencoded (`%09` is the encoded form), and leaving it in would defeat the allowlist itself, because browsers strip tab/LF/CR before reading the scheme.
|
|
204
|
+
> This also rejects `[x](<a\tb>)`, which CommonMark otherwise accepts.
|
|
205
|
+
|
|
206
|
+
> **Changed in 0.5.0** — an `orderedList` `start` outside 0–999999999 is clamped instead of emitted verbatim, and a code fence `language` is truncated at the first newline.
|
|
207
|
+
> Both previously produced output that reparsed into a different document; see [Defined normalizations](#defined-normalizations).
|
|
208
|
+
|
|
209
|
+
## What `from_dict` validates
|
|
210
|
+
|
|
211
|
+
The Tiptap-side schema is closed: every node/mark type must have a markdown mapping.
|
|
212
|
+
`from_dict` is the single enforcement point and rejects unknown types instead of silently dropping content.
|
|
213
|
+
It guarantees, for the whole tree:
|
|
214
|
+
|
|
215
|
+
- **Closed schema.**
|
|
216
|
+
Node types are `doc`, `paragraph`, `text`, `hardBreak`, `heading`, `blockquote`, `codeBlock`, `horizontalRule`, `bulletList`, `orderedList`, `listItem`, `taskList`, `taskItem`, `table`, `tableRow`, `tableHeader`, `tableCell`, `image`, `htmlBlock`, `htmlInline`.
|
|
217
|
+
Marks are `bold`, `italic`, `strike`, `code`, `link`.
|
|
218
|
+
Anything else → `UnknownTypeError`.
|
|
219
|
+
- **`type` is required** on every node and mark.
|
|
220
|
+
- **The root is a `doc`.**
|
|
221
|
+
- **Shapes**: `attrs` is a dict, `content` and `marks` are lists, and every entry of `content`/`marks` is a dict.
|
|
222
|
+
- **Depth** is at most 2048 levels.
|
|
223
|
+
- **No ASCII control character** in a link `href` or image `src`, entity references included (`	`, `	`).
|
|
224
|
+
- **The URI policy**, when `link_schemes`/`image_schemes`/`link_relative`/`image_relative` ask for it — see [Restricting link and image URIs](#restricting-link-and-image-uris).
|
|
225
|
+
|
|
226
|
+
A document that survives `from_dict` always serializes; callers do not need to re-check any of the above.
|
|
227
|
+
`from_markdown` enforces the same two URI rules on what it parses.
|
|
228
|
+
|
|
229
|
+
It deliberately does **not** validate, unless `strict=True` asks it to:
|
|
230
|
+
|
|
231
|
+
- **`attrs` names and value types.**
|
|
232
|
+
Keys are free-form.
|
|
233
|
+
`str`, `int`, and `bool` round-trip unchanged; anything else is coerced to a string (`[1, 2]` → `"[1, 2]"`, `1.5` → `"1.5"`, `None` → `""`), so `to_dict()` will not always give back what you passed in.
|
|
234
|
+
The one exception is the attrs [Refusing lossy conversions](#refusing-lossy-conversions) lists as accepting `None`: there the key is dropped instead of coerced, because a `None` on those means the attr was never set.
|
|
235
|
+
An attr with no markdown form is dropped at serialization — see [Refusing lossy conversions](#refusing-lossy-conversions).
|
|
236
|
+
- **Per-node required attrs**, `strict` included.
|
|
237
|
+
A `heading` without `level`, an `image` without `src`, or a `link` mark without `href` is accepted; the serializer substitutes a default rather than failing.
|
|
238
|
+
`strict` governs the attrs that are present, not which ones must be.
|
|
239
|
+
`{"src": None}` and no `src` key at all are the same thing to every part of the library, the URI policy included.
|
|
240
|
+
- **Content models.**
|
|
241
|
+
Which node may contain which is not checked — a `text` node directly under `doc` is accepted and serialized.
|
|
242
|
+
- **Raw HTML.**
|
|
243
|
+
The URI policy governs `link` marks and `image` nodes, not markup inside `htmlBlock`/`htmlInline`, so `<a href="javascript:...">` passes it.
|
|
244
|
+
Pair the policy with `html=False` to close that.
|
|
245
|
+
- **URI syntax beyond the scheme.**
|
|
246
|
+
Everything after the scheme is opaque, and the stored value is never rewritten — an `href` is checked in its entity-decoded form but stored exactly as given.
|
|
247
|
+
Under `link_relative="allow"` a protocol-relative `//evil.com/x` also passes, since it carries no scheme; `"path_only"` is the setting that rejects it.
|
|
248
|
+
|
|
249
|
+
Structural violations report where they happened:
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
try:
|
|
253
|
+
tm.from_dict({"type": "doc", "content": [{"content": []}]})
|
|
254
|
+
except tm.InvalidNodeError as e:
|
|
255
|
+
e.code # "missing_type"
|
|
256
|
+
e.field # "type"
|
|
257
|
+
e.path # "content[0]"
|
|
258
|
+
e.detail # "node is missing required key 'type'"
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Defined normalizations
|
|
262
|
+
|
|
263
|
+
Markdown cannot represent every Tiptap document exactly.
|
|
264
|
+
Rather than emitting markdown that reparses into a different structure, marktip applies these deterministic (and idempotent) normalizations at serialization time:
|
|
265
|
+
|
|
266
|
+
- **Hard breaks in headings** — ATX headings are single-line, so a `hardBreak` (or a literal newline carried over from setext input) inside a heading serializes as a single space: `heading("a", hardBreak, "b")` → `# a b`.
|
|
267
|
+
- **Multi-block table cells** — blocks inside a cell are joined with `<br>` and the cell is flattened to one line; two paragraphs in a cell reparse as one paragraph containing a `hardBreak`.
|
|
268
|
+
- **Headerless tables** — GFM tables require a header row, so the first row is always emitted as the header; a leading `tableCell` row reparses as `tableHeader`.
|
|
269
|
+
- **Heading levels** — clamped to 1–6 at serialization (`0` → `#`, `7` → `######`).
|
|
270
|
+
- **Ordered list start** — clamped to CommonMark's 0–999999999 (`-5` → `0.`), and the running number stops at that ceiling rather than emitting a 10-digit marker. Only the first number is honoured on reparse, so a repeated ceiling changes nothing.
|
|
271
|
+
- **Code fence info strings** — a `language` is truncated at the first newline, since an info string is a single line; one containing a backtick switches the fence to `~~~`, which carries it losslessly.
|
|
272
|
+
- **Emphasis boundary whitespace** — whitespace touching an emphasis delimiter has no valid markdown form and is expelled outside the marks (`bold("굵게 ")` → `**굵게** `), cf. prosemirror-markdown's `expelEnclosingWhitespace`.
|
|
273
|
+
- **Adjacent same-family lists** — consecutive lists of the same family alternate markers (`-`/`*`, `1.`/`1)`) so they stay separate lists on reparse instead of merging (which would renumber ordered items or spread task checkboxes).
|
|
274
|
+
|
|
275
|
+
## Development
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
python -m pip install .[test]
|
|
279
|
+
python -m pytest
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
For a direct local CMake build:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
|
286
|
+
-Dpybind11_DIR="$(python -m pybind11 --cmakedir)"
|
|
287
|
+
cmake --build build
|
|
288
|
+
PYTHONPATH=python python -m pytest
|
|
289
|
+
PYTHONPATH=python python scripts/benchmark.py
|
|
290
|
+
```
|
marktip-0.6.0/README.md
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# marktip
|
|
2
|
+
|
|
3
|
+
Fast C++/MD4C Markdown conversion for Tiptap-style JSON.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python -m pip install marktip
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Release wheels are built for common CPython versions on Linux, macOS, and Windows.
|
|
12
|
+
If a wheel is not available for a platform, pip can build from the source distribution with a C++17 compiler and standard Python build tooling.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import marktip as tm
|
|
18
|
+
|
|
19
|
+
doc = tm.from_markdown("# Hello")
|
|
20
|
+
ast = doc.to_dict()
|
|
21
|
+
markdown = doc.to_markdown()
|
|
22
|
+
|
|
23
|
+
doc = tm.from_dict(ast)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`from_markdown` follows GFM/CommonMark by default.
|
|
27
|
+
Pass `cjk_friendly=True` to relax the emphasis and strikethrough rules so delimiters next to CJK text still open and close (e.g. `**볼드**은` parses as bold), a non-standard extension that is off by default:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
doc = tm.from_markdown("**볼드**은 강조", cjk_friendly=True)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Strikethrough follows the GFM reference implementations (cmark-gfm/micromark): intra-word `~~` strikes (`a~~b~~c`), including next to CJK letters (`~~삭제~~은`).
|
|
34
|
+
`cjk_friendly` still matters for `*`/`_`, and for `~` in punctuation-adjacent cases such as `~~"인용"~~라고`.
|
|
35
|
+
|
|
36
|
+
Pass `html=False` to parse raw HTML as literal text instead of `htmlBlock`/`htmlInline` nodes.
|
|
37
|
+
Content is preserved (the serializer escapes it), and `<br>` inside table cells still maps to `hardBreak` so marktip's own table output round-trips:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
doc = tm.from_markdown("a <u>x</u> b", html=False)
|
|
41
|
+
# paragraph with the literal text "a <u>x</u> b"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
marktip targets GFM core syntax and canonical Markdown output rather than byte-identical source preservation.
|
|
45
|
+
|
|
46
|
+
### Restricting link and image URIs
|
|
47
|
+
|
|
48
|
+
`[click](javascript:alert(1))` is valid Markdown, so marktip converts it by default.
|
|
49
|
+
A consumer that *stores* the result and renders it later can opt into a scheme allowlist, enforced in C++ rather than as a second traversal in Python:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
doc = tm.from_dict(ast, link_schemes=("http", "https", "mailto"), image_schemes=("https",))
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`link_schemes` and `image_schemes` are separate because the asymmetry is the common case, and both default to `None`, which allows every scheme.
|
|
56
|
+
An empty tuple allows none.
|
|
57
|
+
Scheme comparison is case-insensitive, and entity references are resolved first, so neither `JavaScript:` nor `javascript:` slips past an `https`-only list.
|
|
58
|
+
|
|
59
|
+
`link_relative` and `image_relative` govern references that carry no scheme at all:
|
|
60
|
+
|
|
61
|
+
| | `/foo`, `#anchor`, `./x.png` | `//evil.com/x` |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `"allow"` (default) | passes | passes |
|
|
64
|
+
| `"path_only"` | passes | rejected |
|
|
65
|
+
| `"reject"` | rejected | rejected |
|
|
66
|
+
|
|
67
|
+
The two axes are independent: `link_relative="reject"` with no allowlist means "any scheme, but it must be absolute".
|
|
68
|
+
|
|
69
|
+
Both write boundaries take the same options, which matters when an editor submits an AST but agents and imports submit Markdown strings:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
doc = tm.from_markdown(untrusted, link_schemes=("https",), image_relative="reject")
|
|
73
|
+
# InvalidNodeError: link href scheme 'javascript' is not allowed
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Violations raise `InvalidNodeError` with `.field` set to `"href"` or `"src"`.
|
|
77
|
+
The options are keyword-only.
|
|
78
|
+
|
|
79
|
+
### Refusing lossy conversions
|
|
80
|
+
|
|
81
|
+
Node and mark *types* are closed, but `attrs` are not.
|
|
82
|
+
An attr the serializer never reads is dropped without a word, so a `colspan: 2` cell quietly becomes a plain one:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
tm.from_dict(ast).to_markdown() # '| x |\n| --- |' — the colspan is gone
|
|
86
|
+
tm.from_dict(ast, strict=True) # InvalidNodeError: attr 'colspan' cannot be
|
|
87
|
+
# expressed in markdown: GFM tables have no cell spanning
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`strict=True` refuses anything markdown would drop or alter, from the walk that is already happening, so a consumer does not have to re-traverse the tree in Python to find out whether the document survives intact.
|
|
91
|
+
It is off by default and keyword-only, and it is a `from_dict` option only — the parser emits nothing but attrs it understands, with values in range, so there is nothing on the `from_markdown` path for it to catch.
|
|
92
|
+
|
|
93
|
+
| Node | Accepted attrs |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `heading` | `level`: 1–6 |
|
|
96
|
+
| `codeBlock` | `language`, `info`: single-line `str` or `None` |
|
|
97
|
+
| `bulletList`, `taskList` | `tight`: `bool` |
|
|
98
|
+
| `orderedList` | `start`: 0–999999999, `tight`: `bool` |
|
|
99
|
+
| `taskItem` | `checked`: `bool` |
|
|
100
|
+
| `table` | `colCount`: non-negative `int` |
|
|
101
|
+
| `tableHeader`, `tableCell` | `align`: `"left"`/`"center"`/`"right"`/`None`, `colspan`: `1`, `rowspan`: `1`, `colwidth`: `None` |
|
|
102
|
+
| `image` | `src`, `alt`, `title`: `str` or `None` |
|
|
103
|
+
| `htmlBlock`, `htmlInline` | `html`: `str` |
|
|
104
|
+
| `link` mark | `href`, `title`: `str` or `None` |
|
|
105
|
+
|
|
106
|
+
Every other type takes no attrs at all.
|
|
107
|
+
Under strict, `bool` and `int` stay distinct — `{"level": True}` is refused rather than read as `1`.
|
|
108
|
+
|
|
109
|
+
> **Changed in 0.6.0** — `None` on the attrs above that accept it now means the attr is unset, and the key is dropped on the way in rather than coerced to `""`.
|
|
110
|
+
> Two things move with it: an `image` whose `alt` is `None` *and* which carries content children now renders that child text, and `to_dict()` omits such a key instead of echoing `""`.
|
|
111
|
+
|
|
112
|
+
`None` is accepted exactly where Tiptap's own schema declares `default: null` — `codeBlock`'s `language`/`info`, `image`'s `src`/`alt`/`title`, and `link`'s `href`/`title`.
|
|
113
|
+
ProseMirror serializes every attr in the schema, defaults included, so `None` is what the editor submits for an attr nobody ever filled in, and an unset attr is accepted already; the markdown is the same either way.
|
|
114
|
+
There are two carve-outs.
|
|
115
|
+
`html` is marktip's own attr with no Tiptap counterpart, and the serializer reads it as the node's payload when it is present but empty, so a `None` there would drop content rather than leave an attr unset.
|
|
116
|
+
The rest — `level`, `start`, `tight`, `checked`, `colCount`, `colspan`, `rowspan` — are settings Tiptap gives a non-null default, so a `None` on one of those is a caller mistake and stays refused.
|
|
117
|
+
`align` and `colwidth` are unchanged: `None` was already a *value* there, not an unset marker.
|
|
118
|
+
|
|
119
|
+
`colspan`/`rowspan`/`colwidth` are *known* attrs whose only accepted values are the no-ops Tiptap's table extension puts on every cell.
|
|
120
|
+
Refusing the names outright would leave strict unusable for the documents it exists to check.
|
|
121
|
+
Tiptap's Link extension is the other direction: its default `target` and `rel` have no markdown form, so they are refused, which is the same rule that stops an `onclick` from vanishing silently.
|
|
122
|
+
|
|
123
|
+
Two attrs are accepted and then ignored, and they are the only two.
|
|
124
|
+
The serializer derives a table's column count from its rows, so `colCount` is never read, and it takes cell alignment from the header row only, so `align` on a body row is never read either.
|
|
125
|
+
Both are refused nowhere because `from_markdown` emits both: rejecting them would break re-parsing stored canonical Markdown on the read path, where a write-time refusal is much harder to notice.
|
|
126
|
+
|
|
127
|
+
## Errors
|
|
128
|
+
|
|
129
|
+
Every rejection marktip raises derives from `marktip.MarktipError`, so "the input is malformed" is a single `except` — no need to catch a bare `ValueError` wide enough to swallow an unrelated bug:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
try:
|
|
133
|
+
tm.from_dict(payload).to_markdown()
|
|
134
|
+
except tm.MarktipError as e:
|
|
135
|
+
return 422, {"code": e.code, "path": e.path, "detail": e.detail}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
Exception
|
|
140
|
+
└─ MarktipError .code, .path, .detail
|
|
141
|
+
├─ UnknownTypeError + .type, .kind — type outside the closed schema
|
|
142
|
+
├─ InvalidNodeError + .field — violates the node grammar
|
|
143
|
+
└─ ParseError — markdown could not be parsed
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`.path` is a breadcrumb into the input, e.g. `content[1].content[0].marks[0]`; it is `""` when the failure is at the root or has no location.
|
|
147
|
+
For `from_markdown` it locates the node in the parsed document, since the input itself is a string.
|
|
148
|
+
`.detail` is the same string as `str(e)`.
|
|
149
|
+
`.code` is a stable machine key:
|
|
150
|
+
|
|
151
|
+
| `.code` | Class | Raised when |
|
|
152
|
+
| --- | --- | --- |
|
|
153
|
+
| `unknown_node_type` | `UnknownTypeError` | node `type` is outside the closed schema |
|
|
154
|
+
| `unknown_mark_type` | `UnknownTypeError` | mark `type` is outside the closed schema |
|
|
155
|
+
| `missing_type` | `InvalidNodeError` | a node or mark has no `type` key |
|
|
156
|
+
| `invalid_root` | `InvalidNodeError` | the root node's type is not `doc` |
|
|
157
|
+
| `wrong_type` | `InvalidNodeError` | `attrs`/`content`/`marks`/a child has the wrong Python type |
|
|
158
|
+
| `max_depth` | `InvalidNodeError` | dict nesting exceeds 2048 |
|
|
159
|
+
| `disallowed_scheme` | `InvalidNodeError` | a link `href` / image `src` scheme is outside the allowlist |
|
|
160
|
+
| `disallowed_relative_url` | `InvalidNodeError` | a scheme-less `href`/`src` violates the relative policy |
|
|
161
|
+
| `invalid_uri_char` | `InvalidNodeError` | an `href`/`src` contains an ASCII control character |
|
|
162
|
+
| `unknown_attr` | `InvalidNodeError` | `strict`: the attr has no markdown form for that type |
|
|
163
|
+
| `invalid_attr_value` | `InvalidNodeError` | `strict`: the attr's value has the wrong type or is out of range |
|
|
164
|
+
| `unrepresentable` | `InvalidNodeError` | `strict`: the value is well-formed, but GFM cannot carry it |
|
|
165
|
+
| `markdown_max_depth` | `ParseError` | markdown nesting exceeds 2048 |
|
|
166
|
+
| `parse_failed` | `ParseError` | MD4C could not parse the input |
|
|
167
|
+
|
|
168
|
+
`from_markdown` still raises a plain `TypeError` when the argument is not `str`/`bytes` — that is a caller bug, not a malformed document.
|
|
169
|
+
So is a bad URI-policy option: a non-iterable `link_schemes` raises `TypeError`, and `link_schemes=("https://",)` or `link_relative="nope"` raises `ValueError`.
|
|
170
|
+
|
|
171
|
+
> **Changed in 0.4.0** — these were previously `ValueError` (`from_dict`) and `RuntimeError` (`from_markdown`).
|
|
172
|
+
> `MarktipError` derives from `Exception` directly, so `except ValueError` no longer catches them.
|
|
173
|
+
|
|
174
|
+
> **Changed in 0.5.0** — an ASCII control character in a link `href` or image `src` is now rejected outright, with or without the URI policy.
|
|
175
|
+
> A URI cannot carry one unencoded (`%09` is the encoded form), and leaving it in would defeat the allowlist itself, because browsers strip tab/LF/CR before reading the scheme.
|
|
176
|
+
> This also rejects `[x](<a\tb>)`, which CommonMark otherwise accepts.
|
|
177
|
+
|
|
178
|
+
> **Changed in 0.5.0** — an `orderedList` `start` outside 0–999999999 is clamped instead of emitted verbatim, and a code fence `language` is truncated at the first newline.
|
|
179
|
+
> Both previously produced output that reparsed into a different document; see [Defined normalizations](#defined-normalizations).
|
|
180
|
+
|
|
181
|
+
## What `from_dict` validates
|
|
182
|
+
|
|
183
|
+
The Tiptap-side schema is closed: every node/mark type must have a markdown mapping.
|
|
184
|
+
`from_dict` is the single enforcement point and rejects unknown types instead of silently dropping content.
|
|
185
|
+
It guarantees, for the whole tree:
|
|
186
|
+
|
|
187
|
+
- **Closed schema.**
|
|
188
|
+
Node types are `doc`, `paragraph`, `text`, `hardBreak`, `heading`, `blockquote`, `codeBlock`, `horizontalRule`, `bulletList`, `orderedList`, `listItem`, `taskList`, `taskItem`, `table`, `tableRow`, `tableHeader`, `tableCell`, `image`, `htmlBlock`, `htmlInline`.
|
|
189
|
+
Marks are `bold`, `italic`, `strike`, `code`, `link`.
|
|
190
|
+
Anything else → `UnknownTypeError`.
|
|
191
|
+
- **`type` is required** on every node and mark.
|
|
192
|
+
- **The root is a `doc`.**
|
|
193
|
+
- **Shapes**: `attrs` is a dict, `content` and `marks` are lists, and every entry of `content`/`marks` is a dict.
|
|
194
|
+
- **Depth** is at most 2048 levels.
|
|
195
|
+
- **No ASCII control character** in a link `href` or image `src`, entity references included (`	`, `	`).
|
|
196
|
+
- **The URI policy**, when `link_schemes`/`image_schemes`/`link_relative`/`image_relative` ask for it — see [Restricting link and image URIs](#restricting-link-and-image-uris).
|
|
197
|
+
|
|
198
|
+
A document that survives `from_dict` always serializes; callers do not need to re-check any of the above.
|
|
199
|
+
`from_markdown` enforces the same two URI rules on what it parses.
|
|
200
|
+
|
|
201
|
+
It deliberately does **not** validate, unless `strict=True` asks it to:
|
|
202
|
+
|
|
203
|
+
- **`attrs` names and value types.**
|
|
204
|
+
Keys are free-form.
|
|
205
|
+
`str`, `int`, and `bool` round-trip unchanged; anything else is coerced to a string (`[1, 2]` → `"[1, 2]"`, `1.5` → `"1.5"`, `None` → `""`), so `to_dict()` will not always give back what you passed in.
|
|
206
|
+
The one exception is the attrs [Refusing lossy conversions](#refusing-lossy-conversions) lists as accepting `None`: there the key is dropped instead of coerced, because a `None` on those means the attr was never set.
|
|
207
|
+
An attr with no markdown form is dropped at serialization — see [Refusing lossy conversions](#refusing-lossy-conversions).
|
|
208
|
+
- **Per-node required attrs**, `strict` included.
|
|
209
|
+
A `heading` without `level`, an `image` without `src`, or a `link` mark without `href` is accepted; the serializer substitutes a default rather than failing.
|
|
210
|
+
`strict` governs the attrs that are present, not which ones must be.
|
|
211
|
+
`{"src": None}` and no `src` key at all are the same thing to every part of the library, the URI policy included.
|
|
212
|
+
- **Content models.**
|
|
213
|
+
Which node may contain which is not checked — a `text` node directly under `doc` is accepted and serialized.
|
|
214
|
+
- **Raw HTML.**
|
|
215
|
+
The URI policy governs `link` marks and `image` nodes, not markup inside `htmlBlock`/`htmlInline`, so `<a href="javascript:...">` passes it.
|
|
216
|
+
Pair the policy with `html=False` to close that.
|
|
217
|
+
- **URI syntax beyond the scheme.**
|
|
218
|
+
Everything after the scheme is opaque, and the stored value is never rewritten — an `href` is checked in its entity-decoded form but stored exactly as given.
|
|
219
|
+
Under `link_relative="allow"` a protocol-relative `//evil.com/x` also passes, since it carries no scheme; `"path_only"` is the setting that rejects it.
|
|
220
|
+
|
|
221
|
+
Structural violations report where they happened:
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
try:
|
|
225
|
+
tm.from_dict({"type": "doc", "content": [{"content": []}]})
|
|
226
|
+
except tm.InvalidNodeError as e:
|
|
227
|
+
e.code # "missing_type"
|
|
228
|
+
e.field # "type"
|
|
229
|
+
e.path # "content[0]"
|
|
230
|
+
e.detail # "node is missing required key 'type'"
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Defined normalizations
|
|
234
|
+
|
|
235
|
+
Markdown cannot represent every Tiptap document exactly.
|
|
236
|
+
Rather than emitting markdown that reparses into a different structure, marktip applies these deterministic (and idempotent) normalizations at serialization time:
|
|
237
|
+
|
|
238
|
+
- **Hard breaks in headings** — ATX headings are single-line, so a `hardBreak` (or a literal newline carried over from setext input) inside a heading serializes as a single space: `heading("a", hardBreak, "b")` → `# a b`.
|
|
239
|
+
- **Multi-block table cells** — blocks inside a cell are joined with `<br>` and the cell is flattened to one line; two paragraphs in a cell reparse as one paragraph containing a `hardBreak`.
|
|
240
|
+
- **Headerless tables** — GFM tables require a header row, so the first row is always emitted as the header; a leading `tableCell` row reparses as `tableHeader`.
|
|
241
|
+
- **Heading levels** — clamped to 1–6 at serialization (`0` → `#`, `7` → `######`).
|
|
242
|
+
- **Ordered list start** — clamped to CommonMark's 0–999999999 (`-5` → `0.`), and the running number stops at that ceiling rather than emitting a 10-digit marker. Only the first number is honoured on reparse, so a repeated ceiling changes nothing.
|
|
243
|
+
- **Code fence info strings** — a `language` is truncated at the first newline, since an info string is a single line; one containing a backtick switches the fence to `~~~`, which carries it losslessly.
|
|
244
|
+
- **Emphasis boundary whitespace** — whitespace touching an emphasis delimiter has no valid markdown form and is expelled outside the marks (`bold("굵게 ")` → `**굵게** `), cf. prosemirror-markdown's `expelEnclosingWhitespace`.
|
|
245
|
+
- **Adjacent same-family lists** — consecutive lists of the same family alternate markers (`-`/`*`, `1.`/`1)`) so they stay separate lists on reparse instead of merging (which would renumber ordered items or spread task checkboxes).
|
|
246
|
+
|
|
247
|
+
## Development
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
python -m pip install .[test]
|
|
251
|
+
python -m pytest
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
For a direct local CMake build:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
|
258
|
+
-Dpybind11_DIR="$(python -m pybind11 --cmakedir)"
|
|
259
|
+
cmake --build build
|
|
260
|
+
PYTHONPATH=python python -m pytest
|
|
261
|
+
PYTHONPATH=python python scripts/benchmark.py
|
|
262
|
+
```
|
|
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "marktip"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Fast C++/MD4C Markdown parser and canonical Markdown serializer for Tiptap-style JSON"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|