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.
- cmark_render-0.1.0/LICENSE +23 -0
- cmark_render-0.1.0/MANIFEST.in +4 -0
- cmark_render-0.1.0/PKG-INFO +65 -0
- cmark_render-0.1.0/README.md +46 -0
- cmark_render-0.1.0/pyproject.toml +45 -0
- cmark_render-0.1.0/setup.cfg +4 -0
- cmark_render-0.1.0/setup.py +22 -0
- cmark_render-0.1.0/src/cmark_render/__init__.py +5 -0
- cmark_render-0.1.0/src/cmark_render/_cmark.c +122 -0
- cmark_render-0.1.0/src/cmark_render/_cmark.pyi +1 -0
- cmark_render-0.1.0/src/cmark_render/py.typed +0 -0
- cmark_render-0.1.0/src/cmark_render.egg-info/PKG-INFO +65 -0
- cmark_render-0.1.0/src/cmark_render.egg-info/SOURCES.txt +82 -0
- cmark_render-0.1.0/src/cmark_render.egg-info/dependency_links.txt +1 -0
- cmark_render-0.1.0/src/cmark_render.egg-info/top_level.txt +1 -0
- cmark_render-0.1.0/tests/test_render.py +108 -0
- cmark_render-0.1.0/vendor/cmark-gfm/COPYING +170 -0
- cmark_render-0.1.0/vendor/cmark-gfm/UPSTREAM +6 -0
- cmark_render-0.1.0/vendor/cmark-gfm/extensions/autolink.c +511 -0
- cmark_render-0.1.0/vendor/cmark-gfm/extensions/autolink.h +8 -0
- cmark_render-0.1.0/vendor/cmark-gfm/extensions/cmark-gfm-core-extensions.h +54 -0
- cmark_render-0.1.0/vendor/cmark-gfm/extensions/core-extensions.c +27 -0
- cmark_render-0.1.0/vendor/cmark-gfm/extensions/ext_scanners.c +879 -0
- cmark_render-0.1.0/vendor/cmark-gfm/extensions/ext_scanners.h +24 -0
- cmark_render-0.1.0/vendor/cmark-gfm/extensions/strikethrough.c +167 -0
- cmark_render-0.1.0/vendor/cmark-gfm/extensions/strikethrough.h +9 -0
- cmark_render-0.1.0/vendor/cmark-gfm/extensions/table.c +917 -0
- cmark_render-0.1.0/vendor/cmark-gfm/extensions/table.h +12 -0
- cmark_render-0.1.0/vendor/cmark-gfm/extensions/tagfilter.c +60 -0
- cmark_render-0.1.0/vendor/cmark-gfm/extensions/tagfilter.h +8 -0
- cmark_render-0.1.0/vendor/cmark-gfm/extensions/tasklist.c +156 -0
- cmark_render-0.1.0/vendor/cmark-gfm/extensions/tasklist.h +8 -0
- cmark_render-0.1.0/vendor/cmark-gfm/generated/cmark-gfm_export.h +43 -0
- cmark_render-0.1.0/vendor/cmark-gfm/generated/cmark-gfm_version.h +7 -0
- cmark_render-0.1.0/vendor/cmark-gfm/generated/config.h +76 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/arena.c +104 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/blocks.c +1622 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/buffer.c +278 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/buffer.h +116 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/case_fold_switch.inc +4327 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/chunk.h +135 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/cmark-gfm-extension_api.h +737 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/cmark-gfm.h +833 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/cmark.c +55 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/cmark_ctype.c +44 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/cmark_ctype.h +33 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/commonmark.c +514 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/entities.inc +2138 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/footnotes.c +63 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/footnotes.h +27 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/houdini.h +57 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/houdini_href_e.c +100 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/houdini_html_e.c +66 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/houdini_html_u.c +149 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/html.c +502 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/html.h +27 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/inlines.c +1788 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/inlines.h +29 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/iterator.c +159 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/iterator.h +26 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/latex.c +468 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/linked_list.c +37 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/man.c +274 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/map.c +129 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/map.h +44 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/node.c +1045 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/node.h +167 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/parser.h +59 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/plaintext.c +218 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/plugin.c +36 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/plugin.h +34 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/references.c +43 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/references.h +26 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/registry.c +63 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/registry.h +24 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/render.c +213 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/render.h +62 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/scanners.c +14056 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/scanners.h +70 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/syntax_extension.c +149 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/syntax_extension.h +34 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/utf8.c +317 -0
- cmark_render-0.1.0/vendor/cmark-gfm/src/utf8.h +35 -0
- 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,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,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,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
|
+
|
|
@@ -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()
|