marktip 0.3.0__tar.gz → 0.4.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.
Files changed (32) hide show
  1. {marktip-0.3.0 → marktip-0.4.0}/CMakeLists.txt +1 -1
  2. marktip-0.4.0/PKG-INFO +179 -0
  3. marktip-0.4.0/README.md +151 -0
  4. {marktip-0.3.0 → marktip-0.4.0}/pyproject.toml +1 -1
  5. {marktip-0.3.0 → marktip-0.4.0}/python/marktip/__init__.py +23 -2
  6. {marktip-0.3.0 → marktip-0.4.0}/src/ast.cpp +23 -20
  7. {marktip-0.3.0 → marktip-0.4.0}/src/ast.h +2 -22
  8. marktip-0.4.0/src/errors.h +70 -0
  9. marktip-0.4.0/src/marktip.cpp +111 -0
  10. {marktip-0.3.0 → marktip-0.4.0}/src/parser.cpp +33 -51
  11. {marktip-0.3.0 → marktip-0.4.0}/src/serializer.cpp +3 -2
  12. {marktip-0.3.0 → marktip-0.4.0}/tests/test_document.py +3 -3
  13. marktip-0.4.0/tests/test_errors.py +190 -0
  14. {marktip-0.3.0 → marktip-0.4.0}/tests/test_serialize.py +1 -1
  15. {marktip-0.3.0 → marktip-0.4.0}/tests/test_validate.py +16 -10
  16. marktip-0.3.0/PKG-INFO +0 -146
  17. marktip-0.3.0/README.md +0 -118
  18. marktip-0.3.0/src/marktip.cpp +0 -62
  19. {marktip-0.3.0 → marktip-0.4.0}/.github/workflows/build-distributions.yml +0 -0
  20. {marktip-0.3.0 → marktip-0.4.0}/.gitignore +0 -0
  21. {marktip-0.3.0 → marktip-0.4.0}/LICENSE +0 -0
  22. {marktip-0.3.0 → marktip-0.4.0}/scripts/benchmark.py +0 -0
  23. {marktip-0.3.0 → marktip-0.4.0}/src/parser.h +0 -0
  24. {marktip-0.3.0 → marktip-0.4.0}/src/serializer.h +0 -0
  25. {marktip-0.3.0 → marktip-0.4.0}/tests/test_parse.py +0 -0
  26. {marktip-0.3.0 → marktip-0.4.0}/tests/test_roundtrip.py +0 -0
  27. {marktip-0.3.0 → marktip-0.4.0}/third_party/md4c/LICENSE.md +0 -0
  28. {marktip-0.3.0 → marktip-0.4.0}/third_party/md4c/src/entity.c +0 -0
  29. {marktip-0.3.0 → marktip-0.4.0}/third_party/md4c/src/entity.h +0 -0
  30. {marktip-0.3.0 → marktip-0.4.0}/third_party/md4c/src/md4c.c +0 -0
  31. {marktip-0.3.0 → marktip-0.4.0}/third_party/md4c/src/md4c.h +0 -0
  32. {marktip-0.3.0 → marktip-0.4.0}/third_party/md4c/upstream_metadata/README.md +0 -0
@@ -7,7 +7,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
7
7
  set(CMAKE_CXX_EXTENSIONS OFF)
8
8
 
9
9
  if(NOT DEFINED SKBUILD_PROJECT_VERSION)
10
- set(SKBUILD_PROJECT_VERSION "0.3.0")
10
+ set(SKBUILD_PROJECT_VERSION "0.4.0")
11
11
  endif()
12
12
 
13
13
  set(PYBIND11_FINDPYTHON ON)
marktip-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: marktip
3
+ Version: 0.4.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
+ ## Errors
75
+
76
+ 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:
77
+
78
+ ```python
79
+ try:
80
+ tm.from_dict(payload).to_markdown()
81
+ except tm.MarktipError as e:
82
+ return 422, {"code": e.code, "path": e.path, "detail": e.detail}
83
+ ```
84
+
85
+ ```
86
+ Exception
87
+ └─ MarktipError .code, .path, .detail
88
+ ├─ UnknownTypeError + .type, .kind — type outside the closed schema
89
+ ├─ InvalidNodeError + .field — violates the node grammar
90
+ └─ ParseError — markdown could not be parsed
91
+ ```
92
+
93
+ `.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.
94
+ `.detail` is the same string as `str(e)`.
95
+ `.code` is a stable machine key:
96
+
97
+ | `.code` | Class | Raised when |
98
+ | --- | --- | --- |
99
+ | `unknown_node_type` | `UnknownTypeError` | node `type` is outside the closed schema |
100
+ | `unknown_mark_type` | `UnknownTypeError` | mark `type` is outside the closed schema |
101
+ | `missing_type` | `InvalidNodeError` | a node or mark has no `type` key |
102
+ | `invalid_root` | `InvalidNodeError` | the root node's type is not `doc` |
103
+ | `wrong_type` | `InvalidNodeError` | `attrs`/`content`/`marks`/a child has the wrong Python type |
104
+ | `max_depth` | `InvalidNodeError` | dict nesting exceeds 2048 |
105
+ | `markdown_max_depth` | `ParseError` | markdown nesting exceeds 2048 |
106
+ | `parse_failed` | `ParseError` | MD4C could not parse the input |
107
+
108
+ `from_markdown` still raises a plain `TypeError` when the argument is not `str`/`bytes` — that is a caller bug, not a malformed document.
109
+
110
+ > **Changed in 0.4.0** — these were previously `ValueError` (`from_dict`) and `RuntimeError` (`from_markdown`).
111
+ > `MarktipError` derives from `Exception` directly, so `except ValueError` no longer catches them.
112
+
113
+ ## What `from_dict` validates
114
+
115
+ The Tiptap-side schema is closed: every node/mark type must have a markdown mapping.
116
+ `from_dict` is the single enforcement point and rejects unknown types instead of silently dropping content.
117
+ It guarantees, for the whole tree:
118
+
119
+ - **Closed schema.**
120
+ Node types are `doc`, `paragraph`, `text`, `hardBreak`, `heading`, `blockquote`, `codeBlock`, `horizontalRule`, `bulletList`, `orderedList`, `listItem`, `taskList`, `taskItem`, `table`, `tableRow`, `tableHeader`, `tableCell`, `image`, `htmlBlock`, `htmlInline`.
121
+ Marks are `bold`, `italic`, `strike`, `code`, `link`.
122
+ Anything else → `UnknownTypeError`.
123
+ - **`type` is required** on every node and mark.
124
+ - **The root is a `doc`.**
125
+ - **Shapes**: `attrs` is a dict, `content` and `marks` are lists, and every entry of `content`/`marks` is a dict.
126
+ - **Depth** is at most 2048 levels.
127
+
128
+ A document that survives `from_dict` always serializes; callers do not need to re-check any of the above.
129
+
130
+ It deliberately does **not** validate:
131
+
132
+ - **`attrs` value types.**
133
+ Keys are free-form.
134
+ `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.
135
+ - **Per-node required attrs.**
136
+ A `heading` without `level`, an `image` without `src`, or a `link` mark without `href` is accepted; the serializer substitutes a default rather than failing.
137
+ - **Content models.**
138
+ Which node may contain which is not checked — a `text` node directly under `doc` is accepted and serialized.
139
+
140
+ Structural violations report where they happened:
141
+
142
+ ```python
143
+ try:
144
+ tm.from_dict({"type": "doc", "content": [{"content": []}]})
145
+ except tm.InvalidNodeError as e:
146
+ e.code # "missing_type"
147
+ e.field # "type"
148
+ e.path # "content[0]"
149
+ e.detail # "node is missing required key 'type'"
150
+ ```
151
+
152
+ ## Defined normalizations
153
+
154
+ Markdown cannot represent every Tiptap document exactly.
155
+ Rather than emitting markdown that reparses into a different structure, marktip applies these deterministic (and idempotent) normalizations at serialization time:
156
+
157
+ - **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`.
158
+ - **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`.
159
+ - **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`.
160
+ - **Heading levels** — clamped to 1–6 at serialization (`0` → `#`, `7` → `######`).
161
+ - **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`.
162
+ - **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).
163
+
164
+ ## Development
165
+
166
+ ```bash
167
+ python -m pip install .[test]
168
+ python -m pytest
169
+ ```
170
+
171
+ For a direct local CMake build:
172
+
173
+ ```bash
174
+ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
175
+ -Dpybind11_DIR="$(python -m pybind11 --cmakedir)"
176
+ cmake --build build
177
+ PYTHONPATH=python python -m pytest
178
+ PYTHONPATH=python python scripts/benchmark.py
179
+ ```
@@ -0,0 +1,151 @@
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
+ ## Errors
47
+
48
+ 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:
49
+
50
+ ```python
51
+ try:
52
+ tm.from_dict(payload).to_markdown()
53
+ except tm.MarktipError as e:
54
+ return 422, {"code": e.code, "path": e.path, "detail": e.detail}
55
+ ```
56
+
57
+ ```
58
+ Exception
59
+ └─ MarktipError .code, .path, .detail
60
+ ├─ UnknownTypeError + .type, .kind — type outside the closed schema
61
+ ├─ InvalidNodeError + .field — violates the node grammar
62
+ └─ ParseError — markdown could not be parsed
63
+ ```
64
+
65
+ `.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.
66
+ `.detail` is the same string as `str(e)`.
67
+ `.code` is a stable machine key:
68
+
69
+ | `.code` | Class | Raised when |
70
+ | --- | --- | --- |
71
+ | `unknown_node_type` | `UnknownTypeError` | node `type` is outside the closed schema |
72
+ | `unknown_mark_type` | `UnknownTypeError` | mark `type` is outside the closed schema |
73
+ | `missing_type` | `InvalidNodeError` | a node or mark has no `type` key |
74
+ | `invalid_root` | `InvalidNodeError` | the root node's type is not `doc` |
75
+ | `wrong_type` | `InvalidNodeError` | `attrs`/`content`/`marks`/a child has the wrong Python type |
76
+ | `max_depth` | `InvalidNodeError` | dict nesting exceeds 2048 |
77
+ | `markdown_max_depth` | `ParseError` | markdown nesting exceeds 2048 |
78
+ | `parse_failed` | `ParseError` | MD4C could not parse the input |
79
+
80
+ `from_markdown` still raises a plain `TypeError` when the argument is not `str`/`bytes` — that is a caller bug, not a malformed document.
81
+
82
+ > **Changed in 0.4.0** — these were previously `ValueError` (`from_dict`) and `RuntimeError` (`from_markdown`).
83
+ > `MarktipError` derives from `Exception` directly, so `except ValueError` no longer catches them.
84
+
85
+ ## What `from_dict` validates
86
+
87
+ The Tiptap-side schema is closed: every node/mark type must have a markdown mapping.
88
+ `from_dict` is the single enforcement point and rejects unknown types instead of silently dropping content.
89
+ It guarantees, for the whole tree:
90
+
91
+ - **Closed schema.**
92
+ Node types are `doc`, `paragraph`, `text`, `hardBreak`, `heading`, `blockquote`, `codeBlock`, `horizontalRule`, `bulletList`, `orderedList`, `listItem`, `taskList`, `taskItem`, `table`, `tableRow`, `tableHeader`, `tableCell`, `image`, `htmlBlock`, `htmlInline`.
93
+ Marks are `bold`, `italic`, `strike`, `code`, `link`.
94
+ Anything else → `UnknownTypeError`.
95
+ - **`type` is required** on every node and mark.
96
+ - **The root is a `doc`.**
97
+ - **Shapes**: `attrs` is a dict, `content` and `marks` are lists, and every entry of `content`/`marks` is a dict.
98
+ - **Depth** is at most 2048 levels.
99
+
100
+ A document that survives `from_dict` always serializes; callers do not need to re-check any of the above.
101
+
102
+ It deliberately does **not** validate:
103
+
104
+ - **`attrs` value types.**
105
+ Keys are free-form.
106
+ `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.
107
+ - **Per-node required attrs.**
108
+ A `heading` without `level`, an `image` without `src`, or a `link` mark without `href` is accepted; the serializer substitutes a default rather than failing.
109
+ - **Content models.**
110
+ Which node may contain which is not checked — a `text` node directly under `doc` is accepted and serialized.
111
+
112
+ Structural violations report where they happened:
113
+
114
+ ```python
115
+ try:
116
+ tm.from_dict({"type": "doc", "content": [{"content": []}]})
117
+ except tm.InvalidNodeError as e:
118
+ e.code # "missing_type"
119
+ e.field # "type"
120
+ e.path # "content[0]"
121
+ e.detail # "node is missing required key 'type'"
122
+ ```
123
+
124
+ ## Defined normalizations
125
+
126
+ Markdown cannot represent every Tiptap document exactly.
127
+ Rather than emitting markdown that reparses into a different structure, marktip applies these deterministic (and idempotent) normalizations at serialization time:
128
+
129
+ - **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`.
130
+ - **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`.
131
+ - **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`.
132
+ - **Heading levels** — clamped to 1–6 at serialization (`0` → `#`, `7` → `######`).
133
+ - **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`.
134
+ - **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).
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ python -m pip install .[test]
140
+ python -m pytest
141
+ ```
142
+
143
+ For a direct local CMake build:
144
+
145
+ ```bash
146
+ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
147
+ -Dpybind11_DIR="$(python -m pybind11 --cmakedir)"
148
+ cmake --build build
149
+ PYTHONPATH=python python -m pytest
150
+ PYTHONPATH=python python scripts/benchmark.py
151
+ ```
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
4
4
 
5
5
  [project]
6
6
  name = "marktip"
7
- version = "0.3.0"
7
+ version = "0.4.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"
@@ -1,7 +1,16 @@
1
1
  """Fast Markdown conversion for Tiptap-style JSON."""
2
2
 
3
3
  try:
4
- from ._core import Document, UnknownTypeError, __version__, from_dict, from_markdown
4
+ from ._core import (
5
+ Document,
6
+ InvalidNodeError,
7
+ MarktipError,
8
+ ParseError,
9
+ UnknownTypeError,
10
+ __version__,
11
+ from_dict,
12
+ from_markdown,
13
+ )
5
14
  except ImportError: # pragma: no cover - development-only CMake build fallback
6
15
  import importlib.util
7
16
  import pathlib
@@ -21,8 +30,20 @@ except ImportError: # pragma: no cover - development-only CMake build fallback
21
30
 
22
31
  __version__ = _module.__version__
23
32
  Document = _module.Document
33
+ MarktipError = _module.MarktipError
24
34
  UnknownTypeError = _module.UnknownTypeError
35
+ InvalidNodeError = _module.InvalidNodeError
36
+ ParseError = _module.ParseError
25
37
  from_dict = _module.from_dict
26
38
  from_markdown = _module.from_markdown
27
39
 
28
- __all__ = ["__version__", "Document", "UnknownTypeError", "from_markdown", "from_dict"]
40
+ __all__ = [
41
+ "__version__",
42
+ "Document",
43
+ "MarktipError",
44
+ "UnknownTypeError",
45
+ "InvalidNodeError",
46
+ "ParseError",
47
+ "from_markdown",
48
+ "from_dict",
49
+ ]
@@ -2,7 +2,6 @@
2
2
 
3
3
  #include <algorithm>
4
4
  #include <iterator>
5
- #include <stdexcept>
6
5
 
7
6
  namespace py = pybind11;
8
7
 
@@ -64,16 +63,16 @@ py::object dict_get(py::dict dict, const char* key) {
64
63
  return dict[py::str(key)];
65
64
  }
66
65
 
67
- py::dict expect_dict(py::handle value, std::string_view context) {
66
+ py::dict expect_dict(py::handle value, std::string_view context, const char* field, const PathStack& path) {
68
67
  if (!py::isinstance<py::dict>(value)) {
69
- throw std::invalid_argument(std::string(context) + " must be a dict");
68
+ throw InvalidNodeError("wrong_type", field, std::string(context) + " must be a dict", format_path(path));
70
69
  }
71
70
  return py::reinterpret_borrow<py::dict>(value);
72
71
  }
73
72
 
74
- py::list expect_list(py::handle value, std::string_view context) {
73
+ py::list expect_list(py::handle value, std::string_view context, const char* field, const PathStack& path) {
75
74
  if (!py::isinstance<py::list>(value)) {
76
- throw std::invalid_argument(std::string(context) + " must be a list");
75
+ throw InvalidNodeError("wrong_type", field, std::string(context) + " must be a list", format_path(path));
77
76
  }
78
77
  return py::reinterpret_borrow<py::list>(value);
79
78
  }
@@ -85,10 +84,11 @@ std::string py_to_string(py::handle value) {
85
84
  return py::cast<std::string>(py::str(value));
86
85
  }
87
86
 
88
- std::string required_type(py::dict value, std::string_view context) {
87
+ std::string required_type(py::dict value, std::string_view context, const PathStack& path) {
89
88
  py::object type = dict_get(value, "type");
90
89
  if (type.is_none()) {
91
- throw std::invalid_argument(std::string(context) + " is missing required key 'type'");
90
+ throw InvalidNodeError("missing_type", "type", std::string(context) + " is missing required key 'type'",
91
+ format_path(path));
92
92
  }
93
93
  return py_to_string(type);
94
94
  }
@@ -172,14 +172,14 @@ py::dict node_to_py(const Document& document, std::size_t index) {
172
172
 
173
173
  Mark mark_from_py(py::dict mark_dict, const PathStack& path) {
174
174
  Mark mark;
175
- mark.type = required_type(mark_dict, "mark");
175
+ mark.type = required_type(mark_dict, "mark", path);
176
176
  if (!is_known_mark_type(mark.type)) {
177
177
  throw UnknownTypeError("mark", mark.type, format_path(path));
178
178
  }
179
179
 
180
180
  py::object attrs = dict_get(mark_dict, "attrs");
181
181
  if (!attrs.is_none()) {
182
- mark.attrs = attrs_from_py(expect_dict(attrs, "mark attrs"));
182
+ mark.attrs = attrs_from_py(expect_dict(attrs, "mark attrs", "attrs", path));
183
183
  }
184
184
 
185
185
  return mark;
@@ -188,11 +188,12 @@ Mark mark_from_py(py::dict mark_dict, const PathStack& path) {
188
188
  void fill_node_from_py(Document& document, std::size_t index, py::dict node_dict, std::string_view context,
189
189
  std::size_t depth, PathStack& path) {
190
190
  if (depth > kMaxNodeDepth) {
191
- throw std::invalid_argument("node content nesting exceeds maximum depth");
191
+ throw InvalidNodeError("max_depth", "content", "node content nesting exceeds maximum depth",
192
+ format_path(path));
192
193
  }
193
194
 
194
195
  Node node;
195
- node.type = required_type(node_dict, context);
196
+ node.type = required_type(node_dict, context, path);
196
197
  if (!is_known_node_type(node.type)) {
197
198
  throw UnknownTypeError("node", node.type, format_path(path));
198
199
  }
@@ -203,16 +204,16 @@ void fill_node_from_py(Document& document, std::size_t index, py::dict node_dict
203
204
 
204
205
  py::object attrs = dict_get(node_dict, "attrs");
205
206
  if (!attrs.is_none()) {
206
- node.attrs = attrs_from_py(expect_dict(attrs, "node attrs"));
207
+ node.attrs = attrs_from_py(expect_dict(attrs, "node attrs", "attrs", path));
207
208
  }
208
209
 
209
210
  py::object marks = dict_get(node_dict, "marks");
210
211
  if (!marks.is_none()) {
211
- py::list mark_list = expect_list(marks, "node marks");
212
+ py::list mark_list = expect_list(marks, "node marks", "marks", path);
212
213
  std::size_t mark_index = 0;
213
214
  for (py::handle mark_handle : mark_list) {
214
215
  path.push_back({"marks", mark_index});
215
- node.marks.push_back(mark_from_py(expect_dict(mark_handle, "mark"), path));
216
+ node.marks.push_back(mark_from_py(expect_dict(mark_handle, "mark", "marks", path), path));
216
217
  path.pop_back();
217
218
  ++mark_index;
218
219
  }
@@ -225,12 +226,13 @@ void fill_node_from_py(Document& document, std::size_t index, py::dict node_dict
225
226
  return;
226
227
  }
227
228
 
228
- py::list content_list = expect_list(content, "node content");
229
+ py::list content_list = expect_list(content, "node content", "content", path);
229
230
  std::size_t child_pos = 0;
230
231
  for (py::handle child_handle : content_list) {
231
232
  std::size_t child_index = document.append_child(index, Node{});
232
233
  path.push_back({"content", child_pos});
233
- fill_node_from_py(document, child_index, expect_dict(child_handle, "content child"), "node", depth + 1, path);
234
+ fill_node_from_py(document, child_index, expect_dict(child_handle, "content child", "content", path), "node",
235
+ depth + 1, path);
234
236
  path.pop_back();
235
237
  ++child_pos;
236
238
  }
@@ -390,14 +392,15 @@ py::dict Document::to_dict() const {
390
392
  }
391
393
 
392
394
  Document from_dict_py(py::dict root) {
393
- std::string type = required_type(root, "root node");
395
+ PathStack path;
396
+ path.reserve(32);
397
+
398
+ std::string type = required_type(root, "root node", path);
394
399
  if (type != "doc") {
395
- throw std::invalid_argument("root node must have type 'doc'");
400
+ throw InvalidNodeError("invalid_root", "type", "root node must have type 'doc'", format_path(path));
396
401
  }
397
402
 
398
403
  Document document;
399
- PathStack path;
400
- path.reserve(32);
401
404
  fill_node_from_py(document, 0, root, "root node", 0, path);
402
405
  return document;
403
406
  }
@@ -3,12 +3,13 @@
3
3
  #include <pybind11/pybind11.h>
4
4
 
5
5
  #include <cstddef>
6
- #include <stdexcept>
7
6
  #include <string>
8
7
  #include <string_view>
9
8
  #include <utility>
10
9
  #include <vector>
11
10
 
11
+ #include "errors.h"
12
+
12
13
  namespace marktip {
13
14
 
14
15
  // Deep documents are traversed recursively (to_dict, to_markdown); cap nesting
@@ -47,27 +48,6 @@ struct Node {
47
48
  std::vector<std::size_t> content;
48
49
  };
49
50
 
50
- // Raised by from_dict when a node or mark type is outside the closed schema.
51
- // Subclasses std::invalid_argument so it surfaces as (a subclass of) ValueError.
52
- class UnknownTypeError : public std::invalid_argument {
53
- public:
54
- UnknownTypeError(std::string kind, std::string type_name, std::string path)
55
- : std::invalid_argument("unknown " + kind + " type '" + type_name + "' at " +
56
- (path.empty() ? std::string("root") : path)),
57
- kind_(std::move(kind)),
58
- type_name_(std::move(type_name)),
59
- path_(std::move(path)) {}
60
-
61
- const std::string& kind() const noexcept { return kind_; }
62
- const std::string& type_name() const noexcept { return type_name_; }
63
- const std::string& path() const noexcept { return path_; }
64
-
65
- private:
66
- std::string kind_; // "node" | "mark"
67
- std::string type_name_;
68
- std::string path_; // "content[0].marks[1]"; empty = root
69
- };
70
-
71
51
  void set_attr(AttrList& attrs, std::string key, AttrValue value);
72
52
  const AttrValue* find_attr(const AttrList& attrs, std::string_view key);
73
53
  std::string attr_string(const AttrList& attrs, std::string_view key, std::string fallback = {});
@@ -0,0 +1,70 @@
1
+ #pragma once
2
+
3
+ #include <exception>
4
+ #include <string>
5
+ #include <utility>
6
+
7
+ namespace marktip {
8
+
9
+ // Every error marktip raises for bad input derives from MarktipError,
10
+ // so a caller can express "the document is malformed" as a single except clause.
11
+ //
12
+ // These types are thrown from parse_to_document() while the GIL is released,
13
+ // so they must never hold pybind11 objects — std::string members only.
14
+ class MarktipError : public std::exception {
15
+ public:
16
+ MarktipError(std::string code, std::string message, std::string path = {})
17
+ : code_(std::move(code)), message_(std::move(message)), path_(std::move(path)) {}
18
+
19
+ const char* what() const noexcept override { return message_.c_str(); }
20
+
21
+ const std::string& code() const noexcept { return code_; }
22
+ const std::string& path() const noexcept { return path_; }
23
+
24
+ private:
25
+ std::string code_; // stable machine key, e.g. "wrong_type"
26
+ std::string message_;
27
+ std::string path_; // "content[0].marks[1]"; empty = root / not applicable
28
+ };
29
+
30
+ // Raised by from_dict when a node or mark type is outside the closed schema.
31
+ class UnknownTypeError : public MarktipError {
32
+ public:
33
+ UnknownTypeError(std::string kind, std::string type_name, std::string path)
34
+ // path is passed by copy, not moved: argument evaluation order is unspecified,
35
+ // and the message argument above reads it.
36
+ : MarktipError("unknown_" + kind + "_type",
37
+ "unknown " + kind + " type '" + type_name + "' at " +
38
+ (path.empty() ? std::string("root") : path),
39
+ path),
40
+ kind_(std::move(kind)),
41
+ type_name_(std::move(type_name)) {}
42
+
43
+ const std::string& kind() const noexcept { return kind_; }
44
+ const std::string& type_name() const noexcept { return type_name_; }
45
+
46
+ private:
47
+ std::string kind_; // "node" | "mark"
48
+ std::string type_name_;
49
+ };
50
+
51
+ // Raised when the input violates the node grammar itself: a missing 'type', a non-dict attrs,
52
+ // a non-list content, a root that is not a doc.
53
+ class InvalidNodeError : public MarktipError {
54
+ public:
55
+ InvalidNodeError(std::string code, std::string field, std::string message, std::string path)
56
+ : MarktipError(std::move(code), std::move(message), std::move(path)), field_(std::move(field)) {}
57
+
58
+ const std::string& field() const noexcept { return field_; }
59
+
60
+ private:
61
+ std::string field_; // "type" | "attrs" | "marks" | "content"
62
+ };
63
+
64
+ // Raised by from_markdown when the markdown itself cannot be parsed.
65
+ class ParseError : public MarktipError {
66
+ public:
67
+ ParseError(std::string code, std::string message) : MarktipError(std::move(code), std::move(message)) {}
68
+ };
69
+
70
+ } // namespace marktip