cmark-render 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. cmark_render-0.1.0/LICENSE +23 -0
  2. cmark_render-0.1.0/MANIFEST.in +4 -0
  3. cmark_render-0.1.0/PKG-INFO +65 -0
  4. cmark_render-0.1.0/README.md +46 -0
  5. cmark_render-0.1.0/pyproject.toml +45 -0
  6. cmark_render-0.1.0/setup.cfg +4 -0
  7. cmark_render-0.1.0/setup.py +22 -0
  8. cmark_render-0.1.0/src/cmark_render/__init__.py +5 -0
  9. cmark_render-0.1.0/src/cmark_render/_cmark.c +122 -0
  10. cmark_render-0.1.0/src/cmark_render/_cmark.pyi +1 -0
  11. cmark_render-0.1.0/src/cmark_render/py.typed +0 -0
  12. cmark_render-0.1.0/src/cmark_render.egg-info/PKG-INFO +65 -0
  13. cmark_render-0.1.0/src/cmark_render.egg-info/SOURCES.txt +82 -0
  14. cmark_render-0.1.0/src/cmark_render.egg-info/dependency_links.txt +1 -0
  15. cmark_render-0.1.0/src/cmark_render.egg-info/top_level.txt +1 -0
  16. cmark_render-0.1.0/tests/test_render.py +108 -0
  17. cmark_render-0.1.0/vendor/cmark-gfm/COPYING +170 -0
  18. cmark_render-0.1.0/vendor/cmark-gfm/UPSTREAM +6 -0
  19. cmark_render-0.1.0/vendor/cmark-gfm/extensions/autolink.c +511 -0
  20. cmark_render-0.1.0/vendor/cmark-gfm/extensions/autolink.h +8 -0
  21. cmark_render-0.1.0/vendor/cmark-gfm/extensions/cmark-gfm-core-extensions.h +54 -0
  22. cmark_render-0.1.0/vendor/cmark-gfm/extensions/core-extensions.c +27 -0
  23. cmark_render-0.1.0/vendor/cmark-gfm/extensions/ext_scanners.c +879 -0
  24. cmark_render-0.1.0/vendor/cmark-gfm/extensions/ext_scanners.h +24 -0
  25. cmark_render-0.1.0/vendor/cmark-gfm/extensions/strikethrough.c +167 -0
  26. cmark_render-0.1.0/vendor/cmark-gfm/extensions/strikethrough.h +9 -0
  27. cmark_render-0.1.0/vendor/cmark-gfm/extensions/table.c +917 -0
  28. cmark_render-0.1.0/vendor/cmark-gfm/extensions/table.h +12 -0
  29. cmark_render-0.1.0/vendor/cmark-gfm/extensions/tagfilter.c +60 -0
  30. cmark_render-0.1.0/vendor/cmark-gfm/extensions/tagfilter.h +8 -0
  31. cmark_render-0.1.0/vendor/cmark-gfm/extensions/tasklist.c +156 -0
  32. cmark_render-0.1.0/vendor/cmark-gfm/extensions/tasklist.h +8 -0
  33. cmark_render-0.1.0/vendor/cmark-gfm/generated/cmark-gfm_export.h +43 -0
  34. cmark_render-0.1.0/vendor/cmark-gfm/generated/cmark-gfm_version.h +7 -0
  35. cmark_render-0.1.0/vendor/cmark-gfm/generated/config.h +76 -0
  36. cmark_render-0.1.0/vendor/cmark-gfm/src/arena.c +104 -0
  37. cmark_render-0.1.0/vendor/cmark-gfm/src/blocks.c +1622 -0
  38. cmark_render-0.1.0/vendor/cmark-gfm/src/buffer.c +278 -0
  39. cmark_render-0.1.0/vendor/cmark-gfm/src/buffer.h +116 -0
  40. cmark_render-0.1.0/vendor/cmark-gfm/src/case_fold_switch.inc +4327 -0
  41. cmark_render-0.1.0/vendor/cmark-gfm/src/chunk.h +135 -0
  42. cmark_render-0.1.0/vendor/cmark-gfm/src/cmark-gfm-extension_api.h +737 -0
  43. cmark_render-0.1.0/vendor/cmark-gfm/src/cmark-gfm.h +833 -0
  44. cmark_render-0.1.0/vendor/cmark-gfm/src/cmark.c +55 -0
  45. cmark_render-0.1.0/vendor/cmark-gfm/src/cmark_ctype.c +44 -0
  46. cmark_render-0.1.0/vendor/cmark-gfm/src/cmark_ctype.h +33 -0
  47. cmark_render-0.1.0/vendor/cmark-gfm/src/commonmark.c +514 -0
  48. cmark_render-0.1.0/vendor/cmark-gfm/src/entities.inc +2138 -0
  49. cmark_render-0.1.0/vendor/cmark-gfm/src/footnotes.c +63 -0
  50. cmark_render-0.1.0/vendor/cmark-gfm/src/footnotes.h +27 -0
  51. cmark_render-0.1.0/vendor/cmark-gfm/src/houdini.h +57 -0
  52. cmark_render-0.1.0/vendor/cmark-gfm/src/houdini_href_e.c +100 -0
  53. cmark_render-0.1.0/vendor/cmark-gfm/src/houdini_html_e.c +66 -0
  54. cmark_render-0.1.0/vendor/cmark-gfm/src/houdini_html_u.c +149 -0
  55. cmark_render-0.1.0/vendor/cmark-gfm/src/html.c +502 -0
  56. cmark_render-0.1.0/vendor/cmark-gfm/src/html.h +27 -0
  57. cmark_render-0.1.0/vendor/cmark-gfm/src/inlines.c +1788 -0
  58. cmark_render-0.1.0/vendor/cmark-gfm/src/inlines.h +29 -0
  59. cmark_render-0.1.0/vendor/cmark-gfm/src/iterator.c +159 -0
  60. cmark_render-0.1.0/vendor/cmark-gfm/src/iterator.h +26 -0
  61. cmark_render-0.1.0/vendor/cmark-gfm/src/latex.c +468 -0
  62. cmark_render-0.1.0/vendor/cmark-gfm/src/linked_list.c +37 -0
  63. cmark_render-0.1.0/vendor/cmark-gfm/src/man.c +274 -0
  64. cmark_render-0.1.0/vendor/cmark-gfm/src/map.c +129 -0
  65. cmark_render-0.1.0/vendor/cmark-gfm/src/map.h +44 -0
  66. cmark_render-0.1.0/vendor/cmark-gfm/src/node.c +1045 -0
  67. cmark_render-0.1.0/vendor/cmark-gfm/src/node.h +167 -0
  68. cmark_render-0.1.0/vendor/cmark-gfm/src/parser.h +59 -0
  69. cmark_render-0.1.0/vendor/cmark-gfm/src/plaintext.c +218 -0
  70. cmark_render-0.1.0/vendor/cmark-gfm/src/plugin.c +36 -0
  71. cmark_render-0.1.0/vendor/cmark-gfm/src/plugin.h +34 -0
  72. cmark_render-0.1.0/vendor/cmark-gfm/src/references.c +43 -0
  73. cmark_render-0.1.0/vendor/cmark-gfm/src/references.h +26 -0
  74. cmark_render-0.1.0/vendor/cmark-gfm/src/registry.c +63 -0
  75. cmark_render-0.1.0/vendor/cmark-gfm/src/registry.h +24 -0
  76. cmark_render-0.1.0/vendor/cmark-gfm/src/render.c +213 -0
  77. cmark_render-0.1.0/vendor/cmark-gfm/src/render.h +62 -0
  78. cmark_render-0.1.0/vendor/cmark-gfm/src/scanners.c +14056 -0
  79. cmark_render-0.1.0/vendor/cmark-gfm/src/scanners.h +70 -0
  80. cmark_render-0.1.0/vendor/cmark-gfm/src/syntax_extension.c +149 -0
  81. cmark_render-0.1.0/vendor/cmark-gfm/src/syntax_extension.h +34 -0
  82. cmark_render-0.1.0/vendor/cmark-gfm/src/utf8.c +317 -0
  83. cmark_render-0.1.0/vendor/cmark-gfm/src/utf8.h +35 -0
  84. cmark_render-0.1.0/vendor/cmark-gfm/src/xml.c +182 -0
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2026, Zachary Juang
2
+
3
+ Redistribution and use in source and binary forms, with or without
4
+ modification, are permitted provided that the following conditions are met:
5
+
6
+ 1. Redistributions of source code must retain the above copyright notice,
7
+ this list of conditions and the following disclaimer.
8
+
9
+ 2. Redistributions in binary form must reproduce the above copyright notice,
10
+ this list of conditions and the following disclaimer in the documentation
11
+ and/or other materials provided with the distribution.
12
+
13
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
14
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
15
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
16
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
17
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
23
+ POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,4 @@
1
+ graft vendor
2
+ graft tests
3
+ include src/cmark_render/*.pyi src/cmark_render/py.typed
4
+ global-exclude __pycache__ *.py[oc]
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: cmark-render
3
+ Version: 0.1.0
4
+ Summary: Fast, leak-free markdown to HTML with a vendored cmark-gfm (tables, ~~strikethrough~~, raw HTML).
5
+ Author: Zachary Juang
6
+ License-Expression: BSD-2-Clause
7
+ Project-URL: Source, https://github.com/zachary822/cmark-render
8
+ Keywords: markdown,commonmark,cmark,gfm
9
+ Classifier: Programming Language :: C
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: Implementation :: CPython
12
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.14
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ License-File: vendor/cmark-gfm/COPYING
18
+ Dynamic: license-file
19
+
20
+ # cmark-render
21
+
22
+ Markdown to HTML in C, using a vendored, statically compiled
23
+ [cmark-gfm](https://github.com/github/cmark-gfm).
24
+
25
+ ```python
26
+ from cmark_render import render_html
27
+
28
+ render_html("| a | b |\n|---|---|\n| ~~old~~ | **new** |")
29
+ ```
30
+
31
+ `render_html` renders CommonMark plus:
32
+
33
+ - GFM tables
34
+ - strikethrough, on `~~double~~` tildes only (single `~` is left alone)
35
+ - raw HTML passed through unchanged (`CMARK_OPT_UNSAFE`). **Sanitize the
36
+ output** if the input is untrusted.
37
+
38
+ There are no options. The one function is all the package provides.
39
+
40
+ ## Why not cmarkgfm?
41
+
42
+ `cmarkgfm` 2025.10.22 never frees the syntax tree or the rendered HTML, so
43
+ each call leaks about 4x the size of its input. This binding frees every
44
+ allocation that cmark hands back.
45
+
46
+ It is also safe to use with threads and `fork()`. Renders run without the GIL
47
+ but one at a time, because cmark-gfm keeps a global table of special
48
+ characters that changes during each parse. A `pthread_atfork` handler stops a
49
+ child process from inheriting a held lock.
50
+
51
+ ## Install
52
+
53
+ ```sh
54
+ pip install cmark-render
55
+ ```
56
+
57
+ Wheels are built for CPython 3.14 on macOS arm64 and manylinux x86_64 and
58
+ aarch64. On other platforms, pip builds from the sdist, which needs a C
59
+ compiler.
60
+
61
+ ## License
62
+
63
+ BSD-2-Clause. The vendored cmark-gfm is under its own licenses, in
64
+ `vendor/cmark-gfm/COPYING`. `vendor/cmark-gfm/UPSTREAM` records the upstream
65
+ commit.
@@ -0,0 +1,46 @@
1
+ # cmark-render
2
+
3
+ Markdown to HTML in C, using a vendored, statically compiled
4
+ [cmark-gfm](https://github.com/github/cmark-gfm).
5
+
6
+ ```python
7
+ from cmark_render import render_html
8
+
9
+ render_html("| a | b |\n|---|---|\n| ~~old~~ | **new** |")
10
+ ```
11
+
12
+ `render_html` renders CommonMark plus:
13
+
14
+ - GFM tables
15
+ - strikethrough, on `~~double~~` tildes only (single `~` is left alone)
16
+ - raw HTML passed through unchanged (`CMARK_OPT_UNSAFE`). **Sanitize the
17
+ output** if the input is untrusted.
18
+
19
+ There are no options. The one function is all the package provides.
20
+
21
+ ## Why not cmarkgfm?
22
+
23
+ `cmarkgfm` 2025.10.22 never frees the syntax tree or the rendered HTML, so
24
+ each call leaks about 4x the size of its input. This binding frees every
25
+ allocation that cmark hands back.
26
+
27
+ It is also safe to use with threads and `fork()`. Renders run without the GIL
28
+ but one at a time, because cmark-gfm keeps a global table of special
29
+ characters that changes during each parse. A `pthread_atfork` handler stops a
30
+ child process from inheriting a held lock.
31
+
32
+ ## Install
33
+
34
+ ```sh
35
+ pip install cmark-render
36
+ ```
37
+
38
+ Wheels are built for CPython 3.14 on macOS arm64 and manylinux x86_64 and
39
+ aarch64. On other platforms, pip builds from the sdist, which needs a C
40
+ compiler.
41
+
42
+ ## License
43
+
44
+ BSD-2-Clause. The vendored cmark-gfm is under its own licenses, in
45
+ `vendor/cmark-gfm/COPYING`. `vendor/cmark-gfm/UPSTREAM` records the upstream
46
+ commit.
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cmark-render"
7
+ version = "0.1.0"
8
+ description = "Fast, leak-free markdown to HTML with a vendored cmark-gfm (tables, ~~strikethrough~~, raw HTML)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.14"
11
+ license = "BSD-2-Clause"
12
+ license-files = ["LICENSE", "vendor/cmark-gfm/COPYING"]
13
+ authors = [{ name = "Zachary Juang" }]
14
+ keywords = ["markdown", "commonmark", "cmark", "gfm"]
15
+ classifiers = [
16
+ "Programming Language :: C",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: Implementation :: CPython",
19
+ "Topic :: Text Processing :: Markup :: Markdown",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ [project.urls]
24
+ Source = "https://github.com/zachary822/cmark-render"
25
+
26
+ [tool.setuptools.package-data]
27
+ cmark_render = ["py.typed", "*.pyi"]
28
+
29
+ [tool.setuptools.exclude-package-data]
30
+ cmark_render = ["*.c"]
31
+
32
+ [tool.cibuildwheel]
33
+ build = "cp314-*"
34
+ # GIL builds on glibc only.
35
+ skip = "cp314t-* *-musllinux_*"
36
+ test-command = "python -m unittest discover -s {project}/tests -v"
37
+
38
+ [tool.uv]
39
+ # uv only rebuilds the project when a cache key changes; include the C.
40
+ cache-keys = [
41
+ { file = "pyproject.toml" },
42
+ { file = "setup.py" },
43
+ { file = "src/**/*.c" },
44
+ { file = "vendor/**/*" },
45
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ from pathlib import Path
2
+
3
+ from setuptools import Extension, setup
4
+
5
+ CMARK = Path("vendor/cmark-gfm")
6
+ sources = [
7
+ "src/cmark_render/_cmark.c",
8
+ *sorted(str(path) for path in (CMARK / "src").glob("*.c")),
9
+ *sorted(str(path) for path in (CMARK / "extensions").glob("*.c")),
10
+ ]
11
+
12
+ setup(
13
+ ext_modules=[
14
+ Extension(
15
+ "cmark_render._cmark",
16
+ sources=sources,
17
+ include_dirs=[str(CMARK / d) for d in ("src", "extensions", "generated")],
18
+ # Compiled in, not linked against a shared libcmark-gfm.
19
+ define_macros=[("CMARK_GFM_STATIC_DEFINE", None)],
20
+ )
21
+ ],
22
+ )
@@ -0,0 +1,5 @@
1
+ """Fast, leak-free markdown to HTML with a vendored cmark-gfm."""
2
+
3
+ from cmark_render._cmark import render_html
4
+
5
+ __all__ = ["render_html"]
@@ -0,0 +1,122 @@
1
+ /* Renders markdown with cmark-gfm: CommonMark plus the
2
+ * strikethrough and table extensions, raw HTML passed through
3
+ * (CMARK_OPT_UNSAFE), and strikethrough only on "~~".
4
+ *
5
+ * Every allocation cmark hands back is freed here. The cmarkgfm package
6
+ * leaked the syntax tree and the rendered HTML on each call.
7
+ */
8
+ #define PY_SSIZE_T_CLEAN
9
+ #include <Python.h>
10
+
11
+ #include <pthread.h>
12
+ #include <string.h>
13
+
14
+ #include "cmark-gfm.h"
15
+ #include "cmark-gfm-core-extensions.h"
16
+
17
+ #define RENDER_OPTIONS (CMARK_OPT_UNSAFE | CMARK_OPT_STRIKETHROUGH_DOUBLE_TILDE)
18
+
19
+ static cmark_syntax_extension *strikethrough;
20
+ static cmark_syntax_extension *table;
21
+
22
+ /* While parsing inlines, cmark adds each extension's characters ("~" for
23
+ * strikethrough) to a global table and clears them after
24
+ * (cmark_manage_extensions_special_characters), so a concurrent parse could
25
+ * lose its strikethrough. Renders run without the GIL, one at a time. */
26
+ static pthread_mutex_t render_lock = PTHREAD_MUTEX_INITIALIZER;
27
+
28
+ /* A fork while another thread holds render_lock would leave the child with a
29
+ * lock nothing releases. Forking waits for the render and unlocks in both. */
30
+ static void lock_render(void) { pthread_mutex_lock(&render_lock); }
31
+ static void unlock_render(void) { pthread_mutex_unlock(&render_lock); }
32
+
33
+ static PyObject *render_html(PyObject *module, PyObject *arg) {
34
+ if (!PyUnicode_Check(arg)) {
35
+ PyErr_Format(PyExc_TypeError,
36
+ "render_html() argument must be str, not %.200s",
37
+ Py_TYPE(arg)->tp_name);
38
+ return NULL;
39
+ }
40
+ Py_ssize_t length;
41
+ const char *text = PyUnicode_AsUTF8AndSize(arg, &length);
42
+ if (text == NULL) {
43
+ return NULL;
44
+ }
45
+
46
+ /* text points into arg, which the caller keeps alive for the call. */
47
+ char *html = NULL;
48
+ Py_BEGIN_ALLOW_THREADS
49
+ /* Taken without the GIL, so a thread waiting here can't block one that
50
+ * holds the lock and needs the GIL back. */
51
+ pthread_mutex_lock(&render_lock);
52
+ cmark_parser *parser = cmark_parser_new(RENDER_OPTIONS);
53
+ cmark_parser_attach_syntax_extension(parser, strikethrough);
54
+ cmark_parser_attach_syntax_extension(parser, table);
55
+ cmark_parser_feed(parser, text, (size_t)length);
56
+ /* The parser hands the tree to the caller; cmark_parser_free leaves it. */
57
+ cmark_node *document = cmark_parser_finish(parser);
58
+ if (document != NULL) {
59
+ html = cmark_render_html(document, RENDER_OPTIONS,
60
+ cmark_parser_get_syntax_extensions(parser));
61
+ cmark_node_free(document);
62
+ }
63
+ cmark_parser_free(parser);
64
+ pthread_mutex_unlock(&render_lock);
65
+ Py_END_ALLOW_THREADS
66
+
67
+ if (html == NULL) {
68
+ PyErr_SetString(PyExc_RuntimeError, "cmark returned no document");
69
+ return NULL;
70
+ }
71
+ PyObject *result = PyUnicode_DecodeUTF8(html, (Py_ssize_t)strlen(html), NULL);
72
+ /* cmark_render_html allocates with the tree's allocator, which is the
73
+ * default one for a parser from cmark_parser_new. */
74
+ cmark_get_default_mem_allocator()->free(html);
75
+ return result;
76
+ }
77
+
78
+ static int exec_module(PyObject *module) {
79
+ static int fork_handlers_registered = 0;
80
+ if (!fork_handlers_registered) {
81
+ if (pthread_atfork(lock_render, unlock_render, unlock_render) != 0) {
82
+ PyErr_SetString(PyExc_ImportError, "pthread_atfork failed");
83
+ return -1;
84
+ }
85
+ fork_handlers_registered = 1;
86
+ }
87
+ cmark_gfm_core_extensions_ensure_registered();
88
+ strikethrough = cmark_find_syntax_extension("strikethrough");
89
+ table = cmark_find_syntax_extension("table");
90
+ if (strikethrough == NULL || table == NULL) {
91
+ PyErr_SetString(PyExc_ImportError,
92
+ "cmark-gfm is missing the strikethrough or table extension");
93
+ return -1;
94
+ }
95
+ return 0;
96
+ }
97
+
98
+ PyDoc_STRVAR(render_html_doc,
99
+ "render_html($module, text, /)\n--\n\n"
100
+ "Render markdown to HTML with the strikethrough and table "
101
+ "extensions, passing raw HTML through.");
102
+
103
+ static PyMethodDef methods[] = {
104
+ {"render_html", render_html, METH_O, render_html_doc},
105
+ {NULL, NULL, 0, NULL},
106
+ };
107
+
108
+ static PyModuleDef_Slot slots[] = {
109
+ {Py_mod_exec, exec_module},
110
+ {0, NULL},
111
+ };
112
+
113
+ static struct PyModuleDef module_def = {
114
+ PyModuleDef_HEAD_INIT,
115
+ .m_name = "cmark_render._cmark",
116
+ .m_doc = "Markdown rendering with cmark-gfm.",
117
+ .m_size = 0,
118
+ .m_methods = methods,
119
+ .m_slots = slots,
120
+ };
121
+
122
+ PyMODINIT_FUNC PyInit__cmark(void) { return PyModuleDef_Init(&module_def); }
@@ -0,0 +1 @@
1
+ def render_html(text: str, /) -> str: ...
File without changes
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: cmark-render
3
+ Version: 0.1.0
4
+ Summary: Fast, leak-free markdown to HTML with a vendored cmark-gfm (tables, ~~strikethrough~~, raw HTML).
5
+ Author: Zachary Juang
6
+ License-Expression: BSD-2-Clause
7
+ Project-URL: Source, https://github.com/zachary822/cmark-render
8
+ Keywords: markdown,commonmark,cmark,gfm
9
+ Classifier: Programming Language :: C
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: Implementation :: CPython
12
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.14
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ License-File: vendor/cmark-gfm/COPYING
18
+ Dynamic: license-file
19
+
20
+ # cmark-render
21
+
22
+ Markdown to HTML in C, using a vendored, statically compiled
23
+ [cmark-gfm](https://github.com/github/cmark-gfm).
24
+
25
+ ```python
26
+ from cmark_render import render_html
27
+
28
+ render_html("| a | b |\n|---|---|\n| ~~old~~ | **new** |")
29
+ ```
30
+
31
+ `render_html` renders CommonMark plus:
32
+
33
+ - GFM tables
34
+ - strikethrough, on `~~double~~` tildes only (single `~` is left alone)
35
+ - raw HTML passed through unchanged (`CMARK_OPT_UNSAFE`). **Sanitize the
36
+ output** if the input is untrusted.
37
+
38
+ There are no options. The one function is all the package provides.
39
+
40
+ ## Why not cmarkgfm?
41
+
42
+ `cmarkgfm` 2025.10.22 never frees the syntax tree or the rendered HTML, so
43
+ each call leaks about 4x the size of its input. This binding frees every
44
+ allocation that cmark hands back.
45
+
46
+ It is also safe to use with threads and `fork()`. Renders run without the GIL
47
+ but one at a time, because cmark-gfm keeps a global table of special
48
+ characters that changes during each parse. A `pthread_atfork` handler stops a
49
+ child process from inheriting a held lock.
50
+
51
+ ## Install
52
+
53
+ ```sh
54
+ pip install cmark-render
55
+ ```
56
+
57
+ Wheels are built for CPython 3.14 on macOS arm64 and manylinux x86_64 and
58
+ aarch64. On other platforms, pip builds from the sdist, which needs a C
59
+ compiler.
60
+
61
+ ## License
62
+
63
+ BSD-2-Clause. The vendored cmark-gfm is under its own licenses, in
64
+ `vendor/cmark-gfm/COPYING`. `vendor/cmark-gfm/UPSTREAM` records the upstream
65
+ commit.
@@ -0,0 +1,82 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ setup.py
6
+ src/cmark_render/__init__.py
7
+ src/cmark_render/_cmark.c
8
+ src/cmark_render/_cmark.pyi
9
+ src/cmark_render/py.typed
10
+ src/cmark_render.egg-info/PKG-INFO
11
+ src/cmark_render.egg-info/SOURCES.txt
12
+ src/cmark_render.egg-info/dependency_links.txt
13
+ src/cmark_render.egg-info/top_level.txt
14
+ tests/test_render.py
15
+ vendor/cmark-gfm/COPYING
16
+ vendor/cmark-gfm/UPSTREAM
17
+ vendor/cmark-gfm/extensions/autolink.c
18
+ vendor/cmark-gfm/extensions/autolink.h
19
+ vendor/cmark-gfm/extensions/cmark-gfm-core-extensions.h
20
+ vendor/cmark-gfm/extensions/core-extensions.c
21
+ vendor/cmark-gfm/extensions/ext_scanners.c
22
+ vendor/cmark-gfm/extensions/ext_scanners.h
23
+ vendor/cmark-gfm/extensions/strikethrough.c
24
+ vendor/cmark-gfm/extensions/strikethrough.h
25
+ vendor/cmark-gfm/extensions/table.c
26
+ vendor/cmark-gfm/extensions/table.h
27
+ vendor/cmark-gfm/extensions/tagfilter.c
28
+ vendor/cmark-gfm/extensions/tagfilter.h
29
+ vendor/cmark-gfm/extensions/tasklist.c
30
+ vendor/cmark-gfm/extensions/tasklist.h
31
+ vendor/cmark-gfm/generated/cmark-gfm_export.h
32
+ vendor/cmark-gfm/generated/cmark-gfm_version.h
33
+ vendor/cmark-gfm/generated/config.h
34
+ vendor/cmark-gfm/src/arena.c
35
+ vendor/cmark-gfm/src/blocks.c
36
+ vendor/cmark-gfm/src/buffer.c
37
+ vendor/cmark-gfm/src/buffer.h
38
+ vendor/cmark-gfm/src/case_fold_switch.inc
39
+ vendor/cmark-gfm/src/chunk.h
40
+ vendor/cmark-gfm/src/cmark-gfm-extension_api.h
41
+ vendor/cmark-gfm/src/cmark-gfm.h
42
+ vendor/cmark-gfm/src/cmark.c
43
+ vendor/cmark-gfm/src/cmark_ctype.c
44
+ vendor/cmark-gfm/src/cmark_ctype.h
45
+ vendor/cmark-gfm/src/commonmark.c
46
+ vendor/cmark-gfm/src/entities.inc
47
+ vendor/cmark-gfm/src/footnotes.c
48
+ vendor/cmark-gfm/src/footnotes.h
49
+ vendor/cmark-gfm/src/houdini.h
50
+ vendor/cmark-gfm/src/houdini_href_e.c
51
+ vendor/cmark-gfm/src/houdini_html_e.c
52
+ vendor/cmark-gfm/src/houdini_html_u.c
53
+ vendor/cmark-gfm/src/html.c
54
+ vendor/cmark-gfm/src/html.h
55
+ vendor/cmark-gfm/src/inlines.c
56
+ vendor/cmark-gfm/src/inlines.h
57
+ vendor/cmark-gfm/src/iterator.c
58
+ vendor/cmark-gfm/src/iterator.h
59
+ vendor/cmark-gfm/src/latex.c
60
+ vendor/cmark-gfm/src/linked_list.c
61
+ vendor/cmark-gfm/src/man.c
62
+ vendor/cmark-gfm/src/map.c
63
+ vendor/cmark-gfm/src/map.h
64
+ vendor/cmark-gfm/src/node.c
65
+ vendor/cmark-gfm/src/node.h
66
+ vendor/cmark-gfm/src/parser.h
67
+ vendor/cmark-gfm/src/plaintext.c
68
+ vendor/cmark-gfm/src/plugin.c
69
+ vendor/cmark-gfm/src/plugin.h
70
+ vendor/cmark-gfm/src/references.c
71
+ vendor/cmark-gfm/src/references.h
72
+ vendor/cmark-gfm/src/registry.c
73
+ vendor/cmark-gfm/src/registry.h
74
+ vendor/cmark-gfm/src/render.c
75
+ vendor/cmark-gfm/src/render.h
76
+ vendor/cmark-gfm/src/scanners.c
77
+ vendor/cmark-gfm/src/scanners.h
78
+ vendor/cmark-gfm/src/syntax_extension.c
79
+ vendor/cmark-gfm/src/syntax_extension.h
80
+ vendor/cmark-gfm/src/utf8.c
81
+ vendor/cmark-gfm/src/utf8.h
82
+ vendor/cmark-gfm/src/xml.c
@@ -0,0 +1 @@
1
+ cmark_render
@@ -0,0 +1,108 @@
1
+ import os
2
+ import resource
3
+ import signal
4
+ import sys
5
+ import threading
6
+ import time
7
+ import unittest
8
+ from concurrent.futures import ThreadPoolExecutor
9
+
10
+ from cmark_render import render_html
11
+
12
+
13
+ class RenderHtmlTests(unittest.TestCase):
14
+ def test_strikethrough(self) -> None:
15
+ self.assertIn("<del>old</del>", render_html("The ~~old~~ road"))
16
+
17
+ def test_single_tildes_stay_literal(self) -> None:
18
+ rendered = render_html('"Got~cha~♪"')
19
+
20
+ self.assertNotIn("<del>", rendered)
21
+ self.assertIn("Got~cha~♪", rendered)
22
+
23
+ def test_passes_raw_html_through(self) -> None:
24
+ self.assertEqual(
25
+ render_html("<em>Roar</em> of the <Dragon King>"),
26
+ "<p><em>Roar</em> of the <Dragon King></p>\n",
27
+ )
28
+
29
+ def test_tables(self) -> None:
30
+ rendered = render_html("| Name | Rank |\n|---|:-:|\n| Kaito | S |")
31
+
32
+ self.assertIn("<table>", rendered)
33
+ self.assertIn("<th>Name</th>", rendered)
34
+ self.assertIn('<td align="center">S</td>', rendered)
35
+
36
+ def test_unicode(self) -> None:
37
+ self.assertEqual(
38
+ render_html("「会話」と**強調** 🐉"),
39
+ "<p>「会話」と<strong>強調</strong> 🐉</p>\n",
40
+ )
41
+
42
+ def test_rejects_non_str(self) -> None:
43
+ with self.assertRaises(TypeError):
44
+ render_html(b"bytes") # type: ignore[arg-type]
45
+
46
+ def test_releases_memory(self) -> None:
47
+ # cmarkgfm leaked each render's syntax tree and HTML, ~4x the input.
48
+ text = "\n\n".join(["Some *markdown*, ~~struck~~, | a | table |."] * 1800)
49
+ for _ in range(50):
50
+ render_html(text)
51
+ before = _peak_rss_mb()
52
+ for _ in range(2000):
53
+ render_html(text)
54
+
55
+ self.assertLess(_peak_rss_mb() - before, 20)
56
+
57
+ def test_renders_correctly_from_many_threads(self) -> None:
58
+ # Each parse sets and clears cmark's global "~" entry; overlapping
59
+ # parses would drop strikethrough.
60
+ text = "\n\n".join(
61
+ f"Line {n}: the ~~old~~ road and ~~new~~ path" for n in range(40)
62
+ )
63
+ expected = render_html(text)
64
+
65
+ with ThreadPoolExecutor(max_workers=8) as pool:
66
+ results = list(pool.map(lambda _: render_html(text), range(800)))
67
+
68
+ self.assertTrue(all(result == expected for result in results))
69
+
70
+ def test_renders_in_a_child_forked_mid_render(self) -> None:
71
+ text = "\n\n".join(["Some ~~struck~~ text."] * 2000)
72
+ stop = threading.Event()
73
+
74
+ def keep_rendering() -> None:
75
+ while not stop.is_set():
76
+ render_html(text)
77
+
78
+ renderer = threading.Thread(target=keep_rendering)
79
+ renderer.start()
80
+ try:
81
+ time.sleep(0.05)
82
+ pid = os.fork()
83
+ if pid == 0:
84
+ # A render lock inherited in the locked state would hang here.
85
+ ok = "<del>struck</del>" in render_html("Some ~~struck~~ text.")
86
+ os._exit(0 if ok else 1)
87
+ finally:
88
+ stop.set()
89
+ renderer.join()
90
+
91
+ deadline = time.monotonic() + 10
92
+ while (status := os.waitpid(pid, os.WNOHANG))[0] == 0:
93
+ if time.monotonic() > deadline:
94
+ os.kill(pid, signal.SIGKILL)
95
+ os.waitpid(pid, 0)
96
+ self.fail("child process deadlocked rendering markdown")
97
+ time.sleep(0.01)
98
+ self.assertEqual(os.waitstatus_to_exitcode(status[1]), 0)
99
+
100
+
101
+ def _peak_rss_mb() -> float:
102
+ peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
103
+ # ru_maxrss is bytes on macOS and kilobytes on Linux.
104
+ return peak / (1024 * 1024 if sys.platform == "darwin" else 1024)
105
+
106
+
107
+ if __name__ == "__main__":
108
+ unittest.main()