marktip 0.1.0__tar.gz → 0.2.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.1.0 → marktip-0.2.0}/.github/workflows/build-distributions.yml +24 -0
- {marktip-0.1.0 → marktip-0.2.0}/CMakeLists.txt +1 -1
- {marktip-0.1.0 → marktip-0.2.0}/PKG-INFO +12 -3
- {marktip-0.1.0 → marktip-0.2.0}/README.md +11 -2
- {marktip-0.1.0 → marktip-0.2.0}/pyproject.toml +1 -1
- {marktip-0.1.0 → marktip-0.2.0}/src/marktip.cpp +4 -2
- {marktip-0.1.0 → marktip-0.2.0}/src/parser.cpp +4 -4
- {marktip-0.1.0 → marktip-0.2.0}/src/parser.h +1 -1
- {marktip-0.1.0 → marktip-0.2.0}/src/serializer.cpp +163 -9
- {marktip-0.1.0 → marktip-0.2.0}/tests/test_parse.py +14 -0
- marktip-0.2.0/tests/test_roundtrip.py +246 -0
- {marktip-0.1.0 → marktip-0.2.0}/third_party/md4c/src/md4c.c +68 -4
- {marktip-0.1.0 → marktip-0.2.0}/third_party/md4c/src/md4c.h +5 -0
- marktip-0.2.0/third_party/md4c/upstream_metadata/README.md +21 -0
- marktip-0.1.0/tests/test_roundtrip.py +0 -58
- marktip-0.1.0/third_party/md4c/upstream_metadata/README.md +0 -12
- {marktip-0.1.0 → marktip-0.2.0}/.gitignore +0 -0
- {marktip-0.1.0 → marktip-0.2.0}/LICENSE +0 -0
- {marktip-0.1.0 → marktip-0.2.0}/python/marktip/__init__.py +0 -0
- {marktip-0.1.0 → marktip-0.2.0}/scripts/benchmark.py +0 -0
- {marktip-0.1.0 → marktip-0.2.0}/src/ast.cpp +0 -0
- {marktip-0.1.0 → marktip-0.2.0}/src/ast.h +0 -0
- {marktip-0.1.0 → marktip-0.2.0}/src/serializer.h +0 -0
- {marktip-0.1.0 → marktip-0.2.0}/tests/test_document.py +0 -0
- {marktip-0.1.0 → marktip-0.2.0}/tests/test_serialize.py +0 -0
- {marktip-0.1.0 → marktip-0.2.0}/third_party/md4c/LICENSE.md +0 -0
- {marktip-0.1.0 → marktip-0.2.0}/third_party/md4c/src/entity.c +0 -0
- {marktip-0.1.0 → marktip-0.2.0}/third_party/md4c/src/entity.h +0 -0
|
@@ -63,3 +63,27 @@ jobs:
|
|
|
63
63
|
with:
|
|
64
64
|
name: marktip-wheels-${{ matrix.os }}
|
|
65
65
|
path: wheelhouse/*.whl
|
|
66
|
+
|
|
67
|
+
publish:
|
|
68
|
+
name: Publish to PyPI
|
|
69
|
+
needs: [sdist, wheels]
|
|
70
|
+
runs-on: ubuntu-latest
|
|
71
|
+
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
|
72
|
+
|
|
73
|
+
environment:
|
|
74
|
+
name: pypi
|
|
75
|
+
url: https://pypi.org/p/marktip
|
|
76
|
+
|
|
77
|
+
permissions:
|
|
78
|
+
id-token: write
|
|
79
|
+
|
|
80
|
+
steps:
|
|
81
|
+
- name: Download built distributions
|
|
82
|
+
uses: actions/download-artifact@v6
|
|
83
|
+
with:
|
|
84
|
+
pattern: marktip-*
|
|
85
|
+
path: dist
|
|
86
|
+
merge-multiple: true
|
|
87
|
+
|
|
88
|
+
- name: Publish to PyPI
|
|
89
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: marktip
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Fast C++/MD4C Markdown parser and canonical Markdown serializer for Tiptap-style JSON
|
|
5
5
|
Keywords: markdown,tiptap,md4c,parser
|
|
6
6
|
Author: Saneaven (Minjae Kyung)
|
|
@@ -52,8 +52,17 @@ markdown = doc.to_markdown()
|
|
|
52
52
|
doc = tm.from_dict(ast)
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
`from_markdown` follows GFM/CommonMark by default. Pass `cjk_friendly=True` to
|
|
56
|
+
relax the emphasis and strikethrough rules so delimiters next to CJK text still
|
|
57
|
+
open and close (e.g. `**볼드**은` parses as bold), a non-standard extension that
|
|
58
|
+
is off by default:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
doc = tm.from_markdown("**볼드**은 강조", cjk_friendly=True)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
marktip targets GFM core syntax and canonical Markdown output rather than
|
|
65
|
+
byte-identical source preservation.
|
|
57
66
|
|
|
58
67
|
## Development
|
|
59
68
|
|
|
@@ -24,8 +24,17 @@ markdown = doc.to_markdown()
|
|
|
24
24
|
doc = tm.from_dict(ast)
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
`from_markdown` follows GFM/CommonMark by default. Pass `cjk_friendly=True` to
|
|
28
|
+
relax the emphasis and strikethrough rules so delimiters next to CJK text still
|
|
29
|
+
open and close (e.g. `**볼드**은` parses as bold), a non-standard extension that
|
|
30
|
+
is off by default:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
doc = tm.from_markdown("**볼드**은 강조", cjk_friendly=True)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
marktip targets GFM core syntax and canonical Markdown output rather than
|
|
37
|
+
byte-identical source preservation.
|
|
29
38
|
|
|
30
39
|
## Development
|
|
31
40
|
|
|
@@ -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.2.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"
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
namespace py = pybind11;
|
|
7
7
|
|
|
8
8
|
#ifndef MARKTIP_VERSION
|
|
9
|
-
#define MARKTIP_VERSION "0.
|
|
9
|
+
#define MARKTIP_VERSION "0.2.0"
|
|
10
10
|
#endif
|
|
11
11
|
|
|
12
12
|
PYBIND11_MODULE(_core, module) {
|
|
@@ -21,6 +21,8 @@ PYBIND11_MODULE(_core, module) {
|
|
|
21
21
|
},
|
|
22
22
|
"Serialize the document AST to canonical Markdown.");
|
|
23
23
|
|
|
24
|
-
module.def("from_markdown", &marktip::from_markdown_py, py::arg("markdown"), "
|
|
24
|
+
module.def("from_markdown", &marktip::from_markdown_py, py::arg("markdown"), py::arg("cjk_friendly") = false,
|
|
25
|
+
"Parse Markdown into a Document. Set cjk_friendly=True to relax the emphasis rules around CJK text "
|
|
26
|
+
"(non-standard extension; the default follows GFM/CommonMark exactly).");
|
|
25
27
|
module.def("from_dict", &marktip::from_dict_py, py::arg("ast"), "Build a Document from a Tiptap-style JSON dict.");
|
|
26
28
|
}
|
|
@@ -704,11 +704,11 @@ int text_callback(MD_TEXTTYPE type, const MD_CHAR* text, MD_SIZE size, void* use
|
|
|
704
704
|
}
|
|
705
705
|
}
|
|
706
706
|
|
|
707
|
-
Document parse_to_document(const std::string& markdown) {
|
|
707
|
+
Document parse_to_document(const std::string& markdown, bool cjk_friendly) {
|
|
708
708
|
AstBuilder builder(markdown.size());
|
|
709
709
|
MD_PARSER parser {};
|
|
710
710
|
parser.abi_version = 0;
|
|
711
|
-
parser.flags = MD_DIALECT_GITHUB;
|
|
711
|
+
parser.flags = MD_DIALECT_GITHUB | (cjk_friendly ? MD_FLAG_CJKFRIENDLYEMPHASIS : 0);
|
|
712
712
|
parser.enter_block = enter_block_callback;
|
|
713
713
|
parser.leave_block = leave_block_callback;
|
|
714
714
|
parser.enter_span = enter_span_callback;
|
|
@@ -729,7 +729,7 @@ Document parse_to_document(const std::string& markdown) {
|
|
|
729
729
|
|
|
730
730
|
} // namespace
|
|
731
731
|
|
|
732
|
-
Document from_markdown_py(py::object markdown) {
|
|
732
|
+
Document from_markdown_py(py::object markdown, bool cjk_friendly) {
|
|
733
733
|
std::string input;
|
|
734
734
|
if (py::isinstance<py::str>(markdown) || py::isinstance<py::bytes>(markdown)) {
|
|
735
735
|
input = py::cast<std::string>(markdown);
|
|
@@ -740,7 +740,7 @@ Document from_markdown_py(py::object markdown) {
|
|
|
740
740
|
Document document;
|
|
741
741
|
{
|
|
742
742
|
py::gil_scoped_release release;
|
|
743
|
-
document = parse_to_document(input);
|
|
743
|
+
document = parse_to_document(input, cjk_friendly);
|
|
744
744
|
}
|
|
745
745
|
return document;
|
|
746
746
|
}
|
|
@@ -211,6 +211,150 @@ std::string apply_marks(std::string rendered, const std::vector<Mark>& marks, bo
|
|
|
211
211
|
return rendered;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
// A run of consecutive inline nodes sharing the same marks, with its content
|
|
215
|
+
// already rendered. `marks` excludes "code" (code spans are atomic per run).
|
|
216
|
+
struct InlineRun {
|
|
217
|
+
std::vector<Mark> marks;
|
|
218
|
+
std::string content;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
bool is_emphasis_mark(const Mark& mark) {
|
|
222
|
+
return mark.type == "bold" || mark.type == "italic" || mark.type == "strike";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
std::size_t common_prefix(const std::vector<Mark>& a, const std::vector<Mark>& b) {
|
|
226
|
+
std::size_t limit = std::min(a.size(), b.size());
|
|
227
|
+
std::size_t i = 0;
|
|
228
|
+
while (i < limit && a[i] == b[i]) {
|
|
229
|
+
++i;
|
|
230
|
+
}
|
|
231
|
+
return i;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
std::size_t first_emphasis_at_or_after(const std::vector<Mark>& marks, std::size_t from) {
|
|
235
|
+
for (std::size_t i = from; i < marks.size(); ++i) {
|
|
236
|
+
if (is_emphasis_mark(marks[i])) {
|
|
237
|
+
return i;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return marks.size();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
std::string open_delimiter(const Mark& mark) {
|
|
244
|
+
if (mark.type == "bold") {
|
|
245
|
+
return "**";
|
|
246
|
+
}
|
|
247
|
+
if (mark.type == "italic") {
|
|
248
|
+
return "*";
|
|
249
|
+
}
|
|
250
|
+
if (mark.type == "strike") {
|
|
251
|
+
return "~~";
|
|
252
|
+
}
|
|
253
|
+
if (mark.type == "link") {
|
|
254
|
+
return "[";
|
|
255
|
+
}
|
|
256
|
+
return "";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
std::string close_delimiter(const Mark& mark) {
|
|
260
|
+
if (mark.type == "bold") {
|
|
261
|
+
return "**";
|
|
262
|
+
}
|
|
263
|
+
if (mark.type == "italic") {
|
|
264
|
+
return "*";
|
|
265
|
+
}
|
|
266
|
+
if (mark.type == "strike") {
|
|
267
|
+
return "~~";
|
|
268
|
+
}
|
|
269
|
+
if (mark.type == "link") {
|
|
270
|
+
std::string href = mark_attr_string(mark, "href");
|
|
271
|
+
std::string title = mark_attr_string(mark, "title");
|
|
272
|
+
return "](" + escape_link_destination(href) +
|
|
273
|
+
(title.empty() ? "" : " \"" + escape_title(title) + "\"") + ")";
|
|
274
|
+
}
|
|
275
|
+
return "";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Whitespace adjacent to an emphasis delimiter prevents the delimiter from
|
|
279
|
+
// opening/closing and no markdown syntax can express it, so such whitespace is
|
|
280
|
+
// moved outside the emphasis marks (cf. prosemirror-markdown
|
|
281
|
+
// expelEnclosingWhitespace). It stays inside enclosing non-emphasis marks
|
|
282
|
+
// such as links, which have no whitespace restriction.
|
|
283
|
+
void expel_boundary_whitespace(std::vector<InlineRun>& runs) {
|
|
284
|
+
static const char* const kWhitespace = " \t\n";
|
|
285
|
+
const std::vector<Mark> no_marks;
|
|
286
|
+
std::vector<InlineRun> result;
|
|
287
|
+
|
|
288
|
+
for (std::size_t i = 0; i < runs.size(); ++i) {
|
|
289
|
+
const std::vector<Mark>& prev = i > 0 ? runs[i - 1].marks : no_marks;
|
|
290
|
+
const std::vector<Mark>& next = i + 1 < runs.size() ? runs[i + 1].marks : no_marks;
|
|
291
|
+
InlineRun& run = runs[i];
|
|
292
|
+
|
|
293
|
+
std::size_t opening_emphasis = first_emphasis_at_or_after(run.marks, common_prefix(prev, run.marks));
|
|
294
|
+
if (opening_emphasis < run.marks.size()) {
|
|
295
|
+
std::size_t body = run.content.find_first_not_of(kWhitespace);
|
|
296
|
+
std::size_t lead_len = body == std::string::npos ? run.content.size() : body;
|
|
297
|
+
if (lead_len > 0) {
|
|
298
|
+
InlineRun lead;
|
|
299
|
+
lead.marks.assign(run.marks.begin(), run.marks.begin() + opening_emphasis);
|
|
300
|
+
lead.content = run.content.substr(0, lead_len);
|
|
301
|
+
run.content.erase(0, lead_len);
|
|
302
|
+
result.push_back(std::move(lead));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
InlineRun tail;
|
|
307
|
+
bool has_tail = false;
|
|
308
|
+
std::size_t closing_emphasis = first_emphasis_at_or_after(run.marks, common_prefix(run.marks, next));
|
|
309
|
+
if (closing_emphasis < run.marks.size() && !run.content.empty()) {
|
|
310
|
+
std::size_t body = run.content.find_last_not_of(kWhitespace);
|
|
311
|
+
std::size_t keep = body == std::string::npos ? 0 : body + 1;
|
|
312
|
+
if (keep < run.content.size()) {
|
|
313
|
+
tail.marks.assign(run.marks.begin(), run.marks.begin() + closing_emphasis);
|
|
314
|
+
tail.content = run.content.substr(keep);
|
|
315
|
+
run.content.erase(keep);
|
|
316
|
+
has_tail = true;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!run.content.empty()) {
|
|
321
|
+
InlineRun kept;
|
|
322
|
+
kept.marks = run.marks;
|
|
323
|
+
kept.content = std::move(run.content);
|
|
324
|
+
result.push_back(std::move(kept));
|
|
325
|
+
}
|
|
326
|
+
if (has_tail) {
|
|
327
|
+
result.push_back(std::move(tail));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
runs = std::move(result);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Emit runs keeping shared marks open across run boundaries, so e.g.
|
|
335
|
+
// [a: bold][b: bold+italic] becomes "**a*b***" rather than "**a*****b***".
|
|
336
|
+
std::string emit_runs(const std::vector<InlineRun>& runs) {
|
|
337
|
+
std::string out;
|
|
338
|
+
std::vector<Mark> open;
|
|
339
|
+
for (const InlineRun& run : runs) {
|
|
340
|
+
std::size_t keep = common_prefix(open, run.marks);
|
|
341
|
+
while (open.size() > keep) {
|
|
342
|
+
out += close_delimiter(open.back());
|
|
343
|
+
open.pop_back();
|
|
344
|
+
}
|
|
345
|
+
for (std::size_t j = keep; j < run.marks.size(); ++j) {
|
|
346
|
+
out += open_delimiter(run.marks[j]);
|
|
347
|
+
open.push_back(run.marks[j]);
|
|
348
|
+
}
|
|
349
|
+
out += run.content;
|
|
350
|
+
}
|
|
351
|
+
while (!open.empty()) {
|
|
352
|
+
out += close_delimiter(open.back());
|
|
353
|
+
open.pop_back();
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
|
|
214
358
|
class MarkdownWriter {
|
|
215
359
|
public:
|
|
216
360
|
explicit MarkdownWriter(const Document& document) : document_(document) {}
|
|
@@ -483,9 +627,9 @@ private:
|
|
|
483
627
|
return rendered;
|
|
484
628
|
}
|
|
485
629
|
|
|
486
|
-
// Render one inline node without applying its marks. Marks are
|
|
487
|
-
//
|
|
488
|
-
//
|
|
630
|
+
// Render one inline node without applying its marks. Marks are emitted by
|
|
631
|
+
// emit_runs(), which keeps marks shared between consecutive runs open
|
|
632
|
+
// across the boundary.
|
|
489
633
|
std::string render_inline_content(const Node& node, bool table_cell, bool in_code) {
|
|
490
634
|
const std::string& type = node.type;
|
|
491
635
|
if (type == "text") {
|
|
@@ -512,7 +656,13 @@ private:
|
|
|
512
656
|
return render_inline(node, table_cell);
|
|
513
657
|
}
|
|
514
658
|
|
|
515
|
-
std::
|
|
659
|
+
std::vector<InlineRun> runs = build_runs(node, table_cell);
|
|
660
|
+
expel_boundary_whitespace(runs);
|
|
661
|
+
return emit_runs(runs);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
std::vector<InlineRun> build_runs(const Node& node, bool table_cell) {
|
|
665
|
+
std::vector<InlineRun> runs;
|
|
516
666
|
const std::vector<std::size_t>& content = node.content;
|
|
517
667
|
std::size_t i = 0;
|
|
518
668
|
while (i < content.size()) {
|
|
@@ -528,14 +678,18 @@ private:
|
|
|
528
678
|
group += render_inline_content(child, table_cell, in_code);
|
|
529
679
|
++j;
|
|
530
680
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
681
|
+
|
|
682
|
+
InlineRun run;
|
|
683
|
+
for (const Mark& mark : first.marks) {
|
|
684
|
+
if (mark.type != "code") {
|
|
685
|
+
run.marks.push_back(mark);
|
|
686
|
+
}
|
|
535
687
|
}
|
|
688
|
+
run.content = in_code ? code_span(group) : std::move(group);
|
|
689
|
+
runs.push_back(std::move(run));
|
|
536
690
|
i = j;
|
|
537
691
|
}
|
|
538
|
-
return
|
|
692
|
+
return runs;
|
|
539
693
|
}
|
|
540
694
|
|
|
541
695
|
std::string render_inline(const Node& node, bool table_cell = false) {
|
|
@@ -81,5 +81,19 @@ def test_parse_maps_br_in_table_cell_to_hard_break():
|
|
|
81
81
|
]
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def test_parse_cjk_friendly_is_opt_in():
|
|
85
|
+
md = "**마크다운(Markdown)**은 표준이다"
|
|
86
|
+
|
|
87
|
+
relaxed = tm.from_markdown(md, cjk_friendly=True).to_dict()
|
|
88
|
+
assert relaxed["content"][0]["content"][0] == {
|
|
89
|
+
"type": "text",
|
|
90
|
+
"text": "마크다운(Markdown)",
|
|
91
|
+
"marks": [{"type": "bold"}],
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
standard = tm.from_markdown(md).to_dict()
|
|
95
|
+
assert standard["content"][0]["content"][0]["text"].startswith("**")
|
|
96
|
+
|
|
97
|
+
|
|
84
98
|
def test_parse_accepts_bytes():
|
|
85
99
|
assert tm.from_markdown(b"# Bytes").to_dict()["content"][0]["content"][0]["text"] == "Bytes"
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
import marktip as tm
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def assert_stable(markdown: str):
|
|
7
|
+
doc = tm.from_markdown(markdown)
|
|
8
|
+
serialized = doc.to_markdown()
|
|
9
|
+
assert tm.from_markdown(serialized).to_dict() == doc.to_dict()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_roundtrip_core_blocks_and_marks():
|
|
13
|
+
assert_stable(
|
|
14
|
+
"# Title\n\n"
|
|
15
|
+
"A **bold** and *italic* paragraph with `code` and ~~strike~~.\n\n"
|
|
16
|
+
"> quoted\n\n"
|
|
17
|
+
"- one\n"
|
|
18
|
+
"- two\n\n"
|
|
19
|
+
"1. first\n"
|
|
20
|
+
"2. second\n"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_roundtrip_gfm_table_task_list_and_code():
|
|
25
|
+
assert_stable(
|
|
26
|
+
"- [x] done\n"
|
|
27
|
+
"- [ ] todo\n\n"
|
|
28
|
+
"| A | B |\n"
|
|
29
|
+
"| :- | -: |\n"
|
|
30
|
+
"| x | y |\n\n"
|
|
31
|
+
"```python\n"
|
|
32
|
+
"print('ok')\n"
|
|
33
|
+
"```\n"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_roundtrip_multi_block_list_items_and_loose_lists():
|
|
38
|
+
assert_stable("- first\n\n second\n")
|
|
39
|
+
assert_stable("- a\n\n- b\n")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_roundtrip_hard_break_inside_marks():
|
|
43
|
+
assert_stable("**a\\\nb**")
|
|
44
|
+
assert_stable("[a\\\nb](https://example.com)")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_roundtrip_block_markers_escaped_in_text():
|
|
48
|
+
assert_stable("\\- not a list")
|
|
49
|
+
assert_stable("\\> not a quote")
|
|
50
|
+
assert_stable("1\\. not ordered")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_roundtrip_ordered_task_list():
|
|
54
|
+
assert_stable("1. [x] done\n2. [ ] todo\n")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_roundtrip_large_and_deep_smoke():
|
|
58
|
+
nested = "- root\n - child\n - grandchild\n"
|
|
59
|
+
large = "\n".join(f"Paragraph {i} with **bold** text." for i in range(200))
|
|
60
|
+
assert_stable(nested + "\n" + large)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
64
|
+
# Adversarial round-trip regressions.
|
|
65
|
+
#
|
|
66
|
+
# The tests above check md -> AST -> md -> AST stability for markdown
|
|
67
|
+
# input. The tests below cover the two remaining invariants of a canonical
|
|
68
|
+
# pipeline:
|
|
69
|
+
#
|
|
70
|
+
# 1. AST identity: from_dict -> to_markdown -> from_markdown == input
|
|
71
|
+
# (an editor document survives serialization; no mark silently drops)
|
|
72
|
+
# 2. Byte idempotence: normalize(normalize(md)) == normalize(md)
|
|
73
|
+
# (re-saving an unchanged document never changes bytes)
|
|
74
|
+
#
|
|
75
|
+
# CJK text next to emphasis delimiters is not recognized by standard
|
|
76
|
+
# CommonMark flanking rules; those cases round-trip only under the
|
|
77
|
+
# non-standard `cjk_friendly=True` parsing extension, so the tests pass the
|
|
78
|
+
# flag explicitly. Whitespace touching an emphasis boundary has no valid
|
|
79
|
+
# markdown representation at all; the serializer expels it outside the
|
|
80
|
+
# delimiters, and the tests assert that normalization.
|
|
81
|
+
|
|
82
|
+
# ── helpers ──────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def doc(*blocks):
|
|
86
|
+
return {"type": "doc", "content": list(blocks)}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def p(*children):
|
|
90
|
+
return {"type": "paragraph", "content": list(children)}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def text(value, *marks):
|
|
94
|
+
node = {"type": "text", "text": value}
|
|
95
|
+
if marks:
|
|
96
|
+
node["marks"] = [{"type": mark} for mark in marks]
|
|
97
|
+
return node
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def hard_break(*marks):
|
|
101
|
+
node = {"type": "hardBreak"}
|
|
102
|
+
if marks:
|
|
103
|
+
node["marks"] = [{"type": mark} for mark in marks]
|
|
104
|
+
return node
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def normalize(md):
|
|
108
|
+
return tm.from_markdown(md).to_markdown()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def assert_ast_roundtrip(ast, cjk_friendly=False):
|
|
112
|
+
md = tm.from_dict(ast).to_markdown()
|
|
113
|
+
back = tm.from_markdown(md, cjk_friendly=cjk_friendly).to_dict()
|
|
114
|
+
assert back == ast, f"serialized markdown: {md!r}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── regression guards: currently passing, must stay green ───────────────
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_plain_cjk_bold():
|
|
121
|
+
assert_ast_roundtrip(doc(p(text("볼드", "bold"), text("입니다"))))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_paren_ordered_marker_escaped():
|
|
125
|
+
# '1)' is a valid CommonMark ordered-list marker; global paren escaping
|
|
126
|
+
# currently prevents the reparse. Guards against relaxing escape_inline.
|
|
127
|
+
assert_ast_roundtrip(doc(p(text("1) 항목처럼 보이는 문장"))))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_dot_ordered_marker_escaped():
|
|
131
|
+
assert_ast_roundtrip(doc(p(text("2026. 연간 회고"))))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_thematic_break_lookalike():
|
|
135
|
+
assert_ast_roundtrip(doc(p(text("---"))))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_setext_lookalike_after_hard_break():
|
|
139
|
+
assert_ast_roundtrip(doc(p(text("제목처럼"), hard_break(), text("==="))))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_code_mark_with_backticks():
|
|
143
|
+
assert_ast_roundtrip(doc(p(text("a`b", "code"))))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_hard_break_inside_bold():
|
|
147
|
+
assert_ast_roundtrip(
|
|
148
|
+
doc(p(text("첫 줄", "bold"), hard_break("bold"), text("둘째 줄", "bold")))
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ── AST identity: CJK-adjacent emphasis (needs cjk_friendly parsing) ─────
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_bold_before_cjk_particle():
|
|
156
|
+
# Standard flanking rejects `)**은` (punctuation before, letter after);
|
|
157
|
+
# the cjk_friendly extension accepts it.
|
|
158
|
+
assert_ast_roundtrip(
|
|
159
|
+
doc(p(text("마크다운(Markdown)", "bold"), text("은 표준이다"))),
|
|
160
|
+
cjk_friendly=True,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_italic_ending_with_fullwidth_stop():
|
|
165
|
+
# `*강조。*다음` — U+3002 is punctuation, '다' is a CJK letter.
|
|
166
|
+
assert_ast_roundtrip(
|
|
167
|
+
doc(p(text("강조。", "italic"), text("다음 문장"))), cjk_friendly=True
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_bold_quoted_word_before_particle():
|
|
172
|
+
# `**"인용"**라고` — '"' before the closing delimiter, CJK letter after.
|
|
173
|
+
assert_ast_roundtrip(
|
|
174
|
+
doc(p(text('"인용"', "bold"), text("라고 말했다"))), cjk_friendly=True
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_strike_before_cjk_particle():
|
|
179
|
+
# `~~삭제~~은` — the strikethrough closer is also blocked by standard rules.
|
|
180
|
+
assert_ast_roundtrip(
|
|
181
|
+
doc(p(text("삭제", "strike"), text("은 취소선"))), cjk_friendly=True
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_cjk_relaxation_is_opt_in():
|
|
186
|
+
# Default parsing must stay exactly GFM/CommonMark: the emphasis does not
|
|
187
|
+
# close, so the delimiters remain literal text.
|
|
188
|
+
md = tm.from_dict(
|
|
189
|
+
doc(p(text("마크다운(Markdown)", "bold"), text("은 표준이다")))
|
|
190
|
+
).to_markdown()
|
|
191
|
+
reparsed = tm.from_markdown(md).to_dict()
|
|
192
|
+
assert reparsed["content"][0]["content"][0]["text"].startswith("**")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ── boundary whitespace: expelled outside the delimiters ─────────────────
|
|
196
|
+
# Whitespace touching an emphasis delimiter has no valid markdown
|
|
197
|
+
# representation, so the serializer moves it outside the marks
|
|
198
|
+
# (cf. prosemirror-markdown expelEnclosingWhitespace). Text content and
|
|
199
|
+
# rendering are unchanged; only the space's mark membership normalizes.
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_trailing_space_inside_bold_is_expelled():
|
|
203
|
+
ast = doc(p(text("굵게 ", "bold"), text("이후 텍스트")))
|
|
204
|
+
md = tm.from_dict(ast).to_markdown()
|
|
205
|
+
assert md == "**굵게** 이후 텍스트"
|
|
206
|
+
assert tm.from_markdown(md).to_dict() == doc(
|
|
207
|
+
p(text("굵게", "bold"), text(" 이후 텍스트"))
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_leading_space_inside_bold_is_expelled():
|
|
212
|
+
ast = doc(p(text("앞"), text(" 굵게", "bold")))
|
|
213
|
+
md = tm.from_dict(ast).to_markdown()
|
|
214
|
+
assert md == "앞 **굵게**"
|
|
215
|
+
assert tm.from_markdown(md).to_dict() == doc(p(text("앞 "), text("굵게", "bold")))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ── mark runs: shared marks stay open across run boundaries ──────────────
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_adjacent_bold_to_bold_italic():
|
|
222
|
+
# Serializes to `**a*b***`: the shared bold stays open, so the reparse
|
|
223
|
+
# restores the exact mark order.
|
|
224
|
+
assert_ast_roundtrip(doc(p(text("a", "bold"), text("b", "bold", "italic"))))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── idempotence: normalize twice == normalize once ───────────────────────
|
|
228
|
+
|
|
229
|
+
IDEMPOTENCE_CORPUS = [
|
|
230
|
+
"**a*****b***",
|
|
231
|
+
"これは**私のやりたかったこと。**だからするの。",
|
|
232
|
+
"**마크다운(Markdown)**은 표준이다",
|
|
233
|
+
"**굵게 **이후",
|
|
234
|
+
"\\*리터럴\\* 별표",
|
|
235
|
+
"- 항목 하나\n- 항목 둘",
|
|
236
|
+
"1. one\n1. two",
|
|
237
|
+
"> 인용\n>\n> 두 문단",
|
|
238
|
+
"`code` 와 **bold** 혼합",
|
|
239
|
+
"a \nb",
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@pytest.mark.parametrize("md", IDEMPOTENCE_CORPUS)
|
|
244
|
+
def test_normalize_is_idempotent(md):
|
|
245
|
+
once = normalize(md)
|
|
246
|
+
assert normalize(once) == once, f"first pass produced: {once!r}"
|
|
@@ -678,6 +678,42 @@ struct MD_UNICODE_FOLD_INFO_tag {
|
|
|
678
678
|
return (md_unicode_bsearch__(codepoint, PUNCT_MAP, SIZEOF_ARRAY(PUNCT_MAP)) >= 0);
|
|
679
679
|
}
|
|
680
680
|
|
|
681
|
+
/* MARKTIP LOCAL PATCH (MD_FLAG_CJKFRIENDLYEMPHASIS):
|
|
682
|
+
* CJK letters (Hangul, kana, Han ideographs, fullwidth forms). Used to
|
|
683
|
+
* relax the emphasis flanking rules around CJK text. */
|
|
684
|
+
static int
|
|
685
|
+
md_is_cjk_char__(unsigned codepoint)
|
|
686
|
+
{
|
|
687
|
+
#define R(cp_min, cp_max) ((cp_min) | 0x40000000), ((cp_max) | 0x80000000)
|
|
688
|
+
#define S(cp) (cp)
|
|
689
|
+
static const unsigned CJK_MAP[] = {
|
|
690
|
+
R(0x1100,0x11ff), /* Hangul jamo */
|
|
691
|
+
S(0x3005), S(0x3007), S(0x303b),
|
|
692
|
+
R(0x3040,0x30fa), /* hiragana, katakana (except middle dot) */
|
|
693
|
+
R(0x30fc,0x30ff),
|
|
694
|
+
R(0x3105,0x312f), /* bopomofo */
|
|
695
|
+
R(0x3130,0x318f), /* Hangul compatibility jamo */
|
|
696
|
+
R(0x31f0,0x31ff), /* katakana phonetic extensions */
|
|
697
|
+
R(0x3400,0x4dbf), /* CJK ideographs extension A */
|
|
698
|
+
R(0x4e00,0x9fff), /* CJK unified ideographs */
|
|
699
|
+
R(0xa960,0xa97f), /* Hangul jamo extended-A */
|
|
700
|
+
R(0xac00,0xd7a3), /* Hangul syllables */
|
|
701
|
+
R(0xd7b0,0xd7ff), /* Hangul jamo extended-B */
|
|
702
|
+
R(0xf900,0xfaff), /* CJK compatibility ideographs */
|
|
703
|
+
R(0xff21,0xff3a), /* fullwidth Latin capitals */
|
|
704
|
+
R(0xff41,0xff5a), /* fullwidth Latin small letters */
|
|
705
|
+
R(0xff66,0xff9f), /* halfwidth katakana */
|
|
706
|
+
R(0x20000,0x3ffff) /* CJK ideographs extensions B..H */
|
|
707
|
+
};
|
|
708
|
+
#undef R
|
|
709
|
+
#undef S
|
|
710
|
+
|
|
711
|
+
if(codepoint < 0x1100)
|
|
712
|
+
return FALSE;
|
|
713
|
+
|
|
714
|
+
return (md_unicode_bsearch__(codepoint, CJK_MAP, SIZEOF_ARRAY(CJK_MAP)) >= 0);
|
|
715
|
+
}
|
|
716
|
+
|
|
681
717
|
static void
|
|
682
718
|
md_get_unicode_fold_info(unsigned codepoint, MD_UNICODE_FOLD_INFO* info)
|
|
683
719
|
{
|
|
@@ -871,6 +907,10 @@ struct MD_UNICODE_FOLD_INFO_tag {
|
|
|
871
907
|
#define ISUNICODEPUNCT(off) md_is_unicode_punct__(md_decode_utf16le__(STR(off), ctx->size - (off), NULL))
|
|
872
908
|
#define ISUNICODEPUNCTBEFORE(off) md_is_unicode_punct__(md_decode_utf16le_before__(ctx, off))
|
|
873
909
|
|
|
910
|
+
/* MARKTIP LOCAL PATCH (MD_FLAG_CJKFRIENDLYEMPHASIS) */
|
|
911
|
+
#define ISCJK(off) md_is_cjk_char__(md_decode_utf16le__(STR(off), ctx->size - (off), NULL))
|
|
912
|
+
#define ISCJKBEFORE(off) md_is_cjk_char__(md_decode_utf16le_before__(ctx, off))
|
|
913
|
+
|
|
874
914
|
static inline int
|
|
875
915
|
md_decode_unicode(const CHAR* str, OFF off, SZ str_size, SZ* p_char_size)
|
|
876
916
|
{
|
|
@@ -952,6 +992,10 @@ struct MD_UNICODE_FOLD_INFO_tag {
|
|
|
952
992
|
#define ISUNICODEPUNCT(off) md_is_unicode_punct__(md_decode_utf8__(STR(off), ctx->size - (off), NULL))
|
|
953
993
|
#define ISUNICODEPUNCTBEFORE(off) md_is_unicode_punct__(md_decode_utf8_before__(ctx, off))
|
|
954
994
|
|
|
995
|
+
/* MARKTIP LOCAL PATCH (MD_FLAG_CJKFRIENDLYEMPHASIS) */
|
|
996
|
+
#define ISCJK(off) md_is_cjk_char__(md_decode_utf8__(STR(off), ctx->size - (off), NULL))
|
|
997
|
+
#define ISCJKBEFORE(off) md_is_cjk_char__(md_decode_utf8_before__(ctx, off))
|
|
998
|
+
|
|
955
999
|
static inline unsigned
|
|
956
1000
|
md_decode_unicode(const CHAR* str, OFF off, SZ str_size, SZ* p_char_size)
|
|
957
1001
|
{
|
|
@@ -965,6 +1009,10 @@ struct MD_UNICODE_FOLD_INFO_tag {
|
|
|
965
1009
|
#define ISUNICODEPUNCT(off) ISPUNCT(off)
|
|
966
1010
|
#define ISUNICODEPUNCTBEFORE(off) ISPUNCT((off)-1)
|
|
967
1011
|
|
|
1012
|
+
/* MARKTIP LOCAL PATCH (MD_FLAG_CJKFRIENDLYEMPHASIS): no CJK in ASCII mode. */
|
|
1013
|
+
#define ISCJK(off) FALSE
|
|
1014
|
+
#define ISCJKBEFORE(off) FALSE
|
|
1015
|
+
|
|
968
1016
|
static inline void
|
|
969
1017
|
md_get_unicode_fold_info(unsigned codepoint, MD_UNICODE_FOLD_INFO* info)
|
|
970
1018
|
{
|
|
@@ -3044,6 +3092,8 @@ md_collect_marks(MD_CTX* ctx, const MD_LINE* lines, MD_SIZE n_lines, int table_m
|
|
|
3044
3092
|
OFF tmp = off+1;
|
|
3045
3093
|
int left_level; /* What precedes: 0 = whitespace; 1 = punctuation; 2 = other char. */
|
|
3046
3094
|
int right_level; /* What follows: 0 = whitespace; 1 = punctuation; 2 = other char. */
|
|
3095
|
+
int left_is_cjk = FALSE;
|
|
3096
|
+
int right_is_cjk = FALSE;
|
|
3047
3097
|
|
|
3048
3098
|
while(tmp < line->end && CH(tmp) == ch)
|
|
3049
3099
|
tmp++;
|
|
@@ -3062,6 +3112,16 @@ md_collect_marks(MD_CTX* ctx, const MD_LINE* lines, MD_SIZE n_lines, int table_m
|
|
|
3062
3112
|
else
|
|
3063
3113
|
right_level = 2;
|
|
3064
3114
|
|
|
3115
|
+
/* MARKTIP LOCAL PATCH: a CJK character next to the delimiter
|
|
3116
|
+
* does not block it from being an opener/closer on the other
|
|
3117
|
+
* side (cf. markdown-it-cjk-friendly). */
|
|
3118
|
+
if(ctx->parser.flags & MD_FLAG_CJKFRIENDLYEMPHASIS) {
|
|
3119
|
+
if(off > line->beg)
|
|
3120
|
+
left_is_cjk = ISCJKBEFORE(off);
|
|
3121
|
+
if(tmp < line->end)
|
|
3122
|
+
right_is_cjk = ISCJK(tmp);
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3065
3125
|
/* Intra-word underscore doesn't have special meaning. */
|
|
3066
3126
|
if(ch == _T('_') && left_level == 2 && right_level == 2) {
|
|
3067
3127
|
left_level = 0;
|
|
@@ -3071,9 +3131,9 @@ md_collect_marks(MD_CTX* ctx, const MD_LINE* lines, MD_SIZE n_lines, int table_m
|
|
|
3071
3131
|
if(left_level != 0 || right_level != 0) {
|
|
3072
3132
|
unsigned flags = 0;
|
|
3073
3133
|
|
|
3074
|
-
if(left_level > 0 && left_level >= right_level)
|
|
3134
|
+
if(left_level > 0 && (left_level >= right_level || right_is_cjk))
|
|
3075
3135
|
flags |= MD_MARK_POTENTIAL_CLOSER;
|
|
3076
|
-
if(right_level > 0 && right_level >= left_level)
|
|
3136
|
+
if(right_level > 0 && (right_level >= left_level || left_is_cjk))
|
|
3077
3137
|
flags |= MD_MARK_POTENTIAL_OPENER;
|
|
3078
3138
|
if(flags == (MD_MARK_POTENTIAL_OPENER | MD_MARK_POTENTIAL_CLOSER))
|
|
3079
3139
|
flags |= MD_MARK_EMPH_OC;
|
|
@@ -3295,10 +3355,14 @@ md_collect_marks(MD_CTX* ctx, const MD_LINE* lines, MD_SIZE n_lines, int table_m
|
|
|
3295
3355
|
|
|
3296
3356
|
if(tmp - off <= 2) {
|
|
3297
3357
|
unsigned flags = MD_MARK_POTENTIAL_OPENER | MD_MARK_POTENTIAL_CLOSER;
|
|
3358
|
+
/* MARKTIP LOCAL PATCH: CJK-friendly strikethrough. */
|
|
3359
|
+
int cjk_friendly = (ch == _T('~') && (ctx->parser.flags & MD_FLAG_CJKFRIENDLYEMPHASIS));
|
|
3298
3360
|
|
|
3299
|
-
if(off > line->beg && !ISUNICODEWHITESPACEBEFORE(off) && !ISUNICODEPUNCTBEFORE(off)
|
|
3361
|
+
if(off > line->beg && !ISUNICODEWHITESPACEBEFORE(off) && !ISUNICODEPUNCTBEFORE(off)
|
|
3362
|
+
&& !(cjk_friendly && ISCJKBEFORE(off)))
|
|
3300
3363
|
flags &= ~MD_MARK_POTENTIAL_OPENER;
|
|
3301
|
-
if(tmp < line->end && !ISUNICODEWHITESPACE(tmp) && !ISUNICODEPUNCT(tmp)
|
|
3364
|
+
if(tmp < line->end && !ISUNICODEWHITESPACE(tmp) && !ISUNICODEPUNCT(tmp)
|
|
3365
|
+
&& !(cjk_friendly && ISCJK(tmp)))
|
|
3302
3366
|
flags &= ~MD_MARK_POTENTIAL_CLOSER;
|
|
3303
3367
|
if(flags != 0)
|
|
3304
3368
|
ADD_MARK(ch, off, tmp, flags);
|
|
@@ -319,6 +319,11 @@ typedef struct MD_SPAN_WIKILINK {
|
|
|
319
319
|
#define MD_FLAG_UNDERLINE 0x4000 /* Enable underline extension (and disables '_' for normal emphasis). */
|
|
320
320
|
#define MD_FLAG_HARD_SOFT_BREAKS 0x8000 /* Force all soft breaks to act as hard breaks. */
|
|
321
321
|
|
|
322
|
+
/* MARKTIP LOCAL PATCH (not upstream): relax emphasis/strikethrough flanking
|
|
323
|
+
* rules so a CJK character next to a delimiter does not prevent it from
|
|
324
|
+
* opening/closing (cf. markdown-it-cjk-friendly). */
|
|
325
|
+
#define MD_FLAG_CJKFRIENDLYEMPHASIS 0x10000
|
|
326
|
+
|
|
322
327
|
#define MD_FLAG_PERMISSIVEAUTOLINKS (MD_FLAG_PERMISSIVEEMAILAUTOLINKS | MD_FLAG_PERMISSIVEURLAUTOLINKS | MD_FLAG_PERMISSIVEWWWAUTOLINKS)
|
|
323
328
|
#define MD_FLAG_NOHTML (MD_FLAG_NOHTMLBLOCKS | MD_FLAG_NOHTMLSPANS)
|
|
324
329
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MD4C vendored source
|
|
2
|
+
|
|
3
|
+
This directory vendors the parser-only MD4C 0.5.3 source files:
|
|
4
|
+
|
|
5
|
+
- `src/md4c.c`
|
|
6
|
+
- `src/md4c.h`
|
|
7
|
+
- `src/entity.c` (HTML entity table, used to decode `MD_TEXT_ENTITY`)
|
|
8
|
+
- `src/entity.h`
|
|
9
|
+
- `LICENSE.md`
|
|
10
|
+
|
|
11
|
+
Upstream: https://github.com/mity/md4c
|
|
12
|
+
Pinned tag: `release-0.5.3`
|
|
13
|
+
|
|
14
|
+
## Local patches (deviations from upstream)
|
|
15
|
+
|
|
16
|
+
- `MD_FLAG_CJKFRIENDLYEMPHASIS` (md4c.h, md4c.c): opt-in flag that relaxes the
|
|
17
|
+
emphasis (`*`, `_`) and strikethrough (`~`) flanking rules so that a CJK
|
|
18
|
+
character next to a delimiter does not prevent it from opening/closing
|
|
19
|
+
(cf. markdown-it-cjk-friendly). Adds `md_is_cjk_char__()` and the
|
|
20
|
+
`ISCJK`/`ISCJKBEFORE` macros. All patched hunks are marked with
|
|
21
|
+
`MARKTIP LOCAL PATCH` comments. Behavior is unchanged unless the flag is set.
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import marktip as tm
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def assert_stable(markdown: str):
|
|
5
|
-
doc = tm.from_markdown(markdown)
|
|
6
|
-
serialized = doc.to_markdown()
|
|
7
|
-
assert tm.from_markdown(serialized).to_dict() == doc.to_dict()
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_roundtrip_core_blocks_and_marks():
|
|
11
|
-
assert_stable(
|
|
12
|
-
"# Title\n\n"
|
|
13
|
-
"A **bold** and *italic* paragraph with `code` and ~~strike~~.\n\n"
|
|
14
|
-
"> quoted\n\n"
|
|
15
|
-
"- one\n"
|
|
16
|
-
"- two\n\n"
|
|
17
|
-
"1. first\n"
|
|
18
|
-
"2. second\n"
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_roundtrip_gfm_table_task_list_and_code():
|
|
23
|
-
assert_stable(
|
|
24
|
-
"- [x] done\n"
|
|
25
|
-
"- [ ] todo\n\n"
|
|
26
|
-
"| A | B |\n"
|
|
27
|
-
"| :- | -: |\n"
|
|
28
|
-
"| x | y |\n\n"
|
|
29
|
-
"```python\n"
|
|
30
|
-
"print('ok')\n"
|
|
31
|
-
"```\n"
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def test_roundtrip_multi_block_list_items_and_loose_lists():
|
|
36
|
-
assert_stable("- first\n\n second\n")
|
|
37
|
-
assert_stable("- a\n\n- b\n")
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_roundtrip_hard_break_inside_marks():
|
|
41
|
-
assert_stable("**a\\\nb**")
|
|
42
|
-
assert_stable("[a\\\nb](https://example.com)")
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def test_roundtrip_block_markers_escaped_in_text():
|
|
46
|
-
assert_stable("\\- not a list")
|
|
47
|
-
assert_stable("\\> not a quote")
|
|
48
|
-
assert_stable("1\\. not ordered")
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def test_roundtrip_ordered_task_list():
|
|
52
|
-
assert_stable("1. [x] done\n2. [ ] todo\n")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def test_roundtrip_large_and_deep_smoke():
|
|
56
|
-
nested = "- root\n - child\n - grandchild\n"
|
|
57
|
-
large = "\n".join(f"Paragraph {i} with **bold** text." for i in range(200))
|
|
58
|
-
assert_stable(nested + "\n" + large)
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# MD4C vendored source
|
|
2
|
-
|
|
3
|
-
This directory vendors the parser-only MD4C 0.5.3 source files:
|
|
4
|
-
|
|
5
|
-
- `src/md4c.c`
|
|
6
|
-
- `src/md4c.h`
|
|
7
|
-
- `src/entity.c` (HTML entity table, used to decode `MD_TEXT_ENTITY`)
|
|
8
|
-
- `src/entity.h`
|
|
9
|
-
- `LICENSE.md`
|
|
10
|
-
|
|
11
|
-
Upstream: https://github.com/mity/md4c
|
|
12
|
-
Pinned tag: `release-0.5.3`
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|