geomd 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.
- geomd-0.1.0/LICENSE +21 -0
- geomd-0.1.0/PKG-INFO +35 -0
- geomd-0.1.0/README.md +17 -0
- geomd-0.1.0/pyproject.toml +40 -0
- geomd-0.1.0/pyproject.toml.orig +40 -0
- geomd-0.1.0/src/geomd/__init__.py +16 -0
- geomd-0.1.0/src/geomd/constants.py +4 -0
- geomd-0.1.0/src/geomd/helpers.py +39 -0
- geomd-0.1.0/src/geomd/models.py +101 -0
- geomd-0.1.0/src/geomd/parsers/__init__.py +0 -0
- geomd-0.1.0/src/geomd/parsers/geojson_parser.py +82 -0
- geomd-0.1.0/src/geomd/parsers/geomarkdown_parser.py +36 -0
- geomd-0.1.0/src/geomd/parsers/json_parser.py +56 -0
- geomd-0.1.0/src/geomd/parsers/markdown_parser.py +83 -0
- geomd-0.1.0/src/geomd/protocol.py +12 -0
- geomd-0.1.0/src/geomd/registry.py +42 -0
- geomd-0.1.0/src/geomd/renderers/__init__.py +0 -0
- geomd-0.1.0/src/geomd/renderers/geojson_renderer.py +70 -0
- geomd-0.1.0/src/geomd/renderers/geomarkdown_renderer.py +33 -0
- geomd-0.1.0/src/geomd/renderers/json_renderer.py +190 -0
- geomd-0.1.0/src/geomd/renderers/markdown_renderer.py +68 -0
geomd-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergei Papulin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
geomd-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: geomd
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GeoMarkdown is a Markdown dialect for geodata. This package provides parsers and renderers that convert between GeoMarkdown and GeoJSON
|
|
5
|
+
Author: Sergei
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Requires-Dist: geojson>=3.3.0
|
|
12
|
+
Requires-Dist: mistune>=3.3.4
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Project-URL: Homepage, https://github.com/spapulin/geomd
|
|
15
|
+
Project-URL: Repository, https://github.com/spapulin/geomd
|
|
16
|
+
Project-URL: Issues, https://github.com/spapulin/fetta/geomd
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# geomd
|
|
20
|
+
|
|
21
|
+
GeoMarkdown is a Markdown dialect for geodata. This package provides parsers and renderers that convert between GeoMarkdown and GeoJSON
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
With uv (recommended):
|
|
26
|
+
|
|
27
|
+
uv add "geomd @ git+https://github.com/spapulin/geomd.git"
|
|
28
|
+
|
|
29
|
+
With pip:
|
|
30
|
+
|
|
31
|
+
pip install "geomd @ git+https://github.com/spapulin/geomd.git"
|
|
32
|
+
|
|
33
|
+
## License
|
|
34
|
+
|
|
35
|
+
[MIT](LICENSE)
|
geomd-0.1.0/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# geomd
|
|
2
|
+
|
|
3
|
+
GeoMarkdown is a Markdown dialect for geodata. This package provides parsers and renderers that convert between GeoMarkdown and GeoJSON
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
With uv (recommended):
|
|
8
|
+
|
|
9
|
+
uv add "geomd @ git+https://github.com/spapulin/geomd.git"
|
|
10
|
+
|
|
11
|
+
With pip:
|
|
12
|
+
|
|
13
|
+
pip install "geomd @ git+https://github.com/spapulin/geomd.git"
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "geomd"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "GeoMarkdown is a Markdown dialect for geodata. This package provides parsers and renderers that convert between GeoMarkdown and GeoJSON"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 3 - Alpha",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"Programming Language :: Python :: 3.12",
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.12"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"geojson>=3.3.0",
|
|
16
|
+
"mistune>=3.3.4",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[[project.authors]]
|
|
20
|
+
name = "Sergei"
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
geomd = "geomd:main"
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/spapulin/geomd"
|
|
27
|
+
Repository = "https://github.com/spapulin/geomd"
|
|
28
|
+
Issues = "https://github.com/spapulin/fetta/geomd"
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["uv_build>=0.12.17,<0.13.0"]
|
|
32
|
+
build-backend = "uv_build"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"ipykernel>=7.3.0",
|
|
37
|
+
"jupyterlab>=4.6.4",
|
|
38
|
+
"pytest>=8",
|
|
39
|
+
"ruff>=0.6",
|
|
40
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "geomd"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "GeoMarkdown is a Markdown dialect for geodata. This package provides parsers and renderers that convert between GeoMarkdown and GeoJSON"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Sergei" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
]
|
|
16
|
+
requires-python = ">=3.12"
|
|
17
|
+
dependencies = [
|
|
18
|
+
"geojson>=3.3.0",
|
|
19
|
+
"mistune>=3.3.4",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
geomd = "geomd:main"
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/spapulin/geomd"
|
|
27
|
+
Repository = "https://github.com/spapulin/geomd"
|
|
28
|
+
Issues = "https://github.com/spapulin/fetta/geomd"
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["uv_build>=0.12.17,<0.13.0"]
|
|
32
|
+
build-backend = "uv_build"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"ipykernel>=7.3.0",
|
|
37
|
+
"jupyterlab>=4.6.4",
|
|
38
|
+
"pytest>=8",
|
|
39
|
+
"ruff>=0.6",
|
|
40
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .models import (
|
|
2
|
+
Node, Heading, Paragraph, List, ListItem,
|
|
3
|
+
Text, CodeBlock, Link, Image, HtmlBlock, GeometryBlock,
|
|
4
|
+
)
|
|
5
|
+
from .registry import convert, parse, render
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Node", "Heading", "Paragraph", "List", "ListItem",
|
|
9
|
+
"Text", "CodeBlock", "Link", "Image", "HtmlBlock",
|
|
10
|
+
"GeometryBlock",
|
|
11
|
+
"convert", "render", "parse"
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from .constants import _EXTRA_FIELDS, _FIELD_MAPPING
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def parse_metadata_from_html_comment(comment: str) -> dict:
|
|
5
|
+
extra_fields, field_mapping = {}, {}
|
|
6
|
+
section = None
|
|
7
|
+
for raw_line in comment.splitlines():
|
|
8
|
+
|
|
9
|
+
line = raw_line.strip()
|
|
10
|
+
if not line:
|
|
11
|
+
continue
|
|
12
|
+
|
|
13
|
+
if line == f"{_EXTRA_FIELDS}:":
|
|
14
|
+
section = extra_fields
|
|
15
|
+
continue
|
|
16
|
+
|
|
17
|
+
if line == f"{_FIELD_MAPPING}:":
|
|
18
|
+
section = field_mapping
|
|
19
|
+
continue
|
|
20
|
+
|
|
21
|
+
if line.startswith("- ") and section is not None:
|
|
22
|
+
key, _, value = line[2:].partition(":")
|
|
23
|
+
section[key.strip()] = value.strip()
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
_EXTRA_FIELDS: extra_fields,
|
|
27
|
+
_FIELD_MAPPING: field_mapping
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render_metadata_as_html_comment(metadata: dict) -> str:
|
|
32
|
+
comment_list = []
|
|
33
|
+
for key in [_EXTRA_FIELDS, _FIELD_MAPPING]:
|
|
34
|
+
if key in metadata and metadata[key]:
|
|
35
|
+
comment_list.append(f"{key}:")
|
|
36
|
+
for attr, value in metadata[key].items():
|
|
37
|
+
comment_list.append(f"\t- {attr}: {value}")
|
|
38
|
+
comments_body = "\n".join(comment_list)
|
|
39
|
+
return f"<!--\n{comments_body}\n-->"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
import geojson
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Node:
|
|
9
|
+
|
|
10
|
+
children: list["Node"] = field(default_factory=list)
|
|
11
|
+
|
|
12
|
+
NODE_TYPE: ClassVar[str] = ""
|
|
13
|
+
|
|
14
|
+
def to_dict(self) -> dict:
|
|
15
|
+
result = {"type": self.NODE_TYPE}
|
|
16
|
+
for attr, val in self.__dict__.items():
|
|
17
|
+
if attr == "children":
|
|
18
|
+
if val:
|
|
19
|
+
result["children"] = [node.to_dict() for node in val]
|
|
20
|
+
elif val not in (None, ""):
|
|
21
|
+
result[attr] = val
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Heading(Node):
|
|
27
|
+
NODE_TYPE = "heading"
|
|
28
|
+
level: int = 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Paragraph(Node):
|
|
33
|
+
NODE_TYPE = "paragraph"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Text(Node):
|
|
38
|
+
NODE_TYPE = "text"
|
|
39
|
+
raw: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Link(Node):
|
|
44
|
+
NODE_TYPE = "link"
|
|
45
|
+
url: str = ""
|
|
46
|
+
title: str = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Image(Node):
|
|
51
|
+
NODE_TYPE = "image"
|
|
52
|
+
url: str = ""
|
|
53
|
+
alt: str = ""
|
|
54
|
+
title: str = ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class CodeBlock(Node):
|
|
59
|
+
NODE_TYPE = "code_block"
|
|
60
|
+
raw: str = ""
|
|
61
|
+
language: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class List(Node):
|
|
66
|
+
NODE_TYPE = "list"
|
|
67
|
+
ordered: bool = False
|
|
68
|
+
start: int = 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class ListItem(Node):
|
|
73
|
+
NODE_TYPE = "list_item"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class HtmlBlock(Node):
|
|
78
|
+
NODE_TYPE = "html_block"
|
|
79
|
+
raw: str = ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class GeometryBlock(Node):
|
|
84
|
+
NODE_TYPE = "geometry_block"
|
|
85
|
+
geometry: geojson.GeoJSON | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# def from_dict(d: dict) -> Node:
|
|
89
|
+
#
|
|
90
|
+
# node_type: str = d["type"]
|
|
91
|
+
# children = [from_dict(c) for c in d.get("children", [])]
|
|
92
|
+
# attrs = {attr: v for attr, v in d.items() if attr not in ("type", "children")}
|
|
93
|
+
#
|
|
94
|
+
# if node_type == Heading.NODE_TYPE:
|
|
95
|
+
# return Heading(children=children, **attrs)
|
|
96
|
+
# elif node_type == Paragraph.NODE_TYPE:
|
|
97
|
+
# return Paragraph(children=children)
|
|
98
|
+
# elif node_type == Text.NODE_TYPE:
|
|
99
|
+
# return Text(children=children, **attrs)
|
|
100
|
+
# else:
|
|
101
|
+
# raise ValueError(f"unknown node type: {node_type}")
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import geojson
|
|
4
|
+
|
|
5
|
+
from ..parsers.json_parser import JsonParser
|
|
6
|
+
from ..helpers import render_metadata_as_html_comment
|
|
7
|
+
from ..models import Node, Heading, Text, Paragraph, GeometryBlock, HtmlBlock
|
|
8
|
+
from ..constants import _METADATA_KEY, _EXTRA_FIELDS, _FIELD_MAPPING
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GeoJsonParser(JsonParser):
|
|
14
|
+
"""
|
|
15
|
+
Parse GeoJSON text into a flat list of document nodes.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def parse(self, text: str) -> list[Node]:
|
|
19
|
+
|
|
20
|
+
geojson_dict = geojson.loads(text)
|
|
21
|
+
geojson_dict.pop("type", None)
|
|
22
|
+
|
|
23
|
+
root_title = geojson_dict.pop("title", None) or ""
|
|
24
|
+
features = geojson_dict.pop("features", [])
|
|
25
|
+
|
|
26
|
+
content: dict = dict(geojson_dict)
|
|
27
|
+
|
|
28
|
+
for feature in features:
|
|
29
|
+
title = feature["title"] or ""
|
|
30
|
+
entry = {}
|
|
31
|
+
|
|
32
|
+
metadata = feature.get(_METADATA_KEY)
|
|
33
|
+
if metadata:
|
|
34
|
+
entry[_METADATA_KEY] = metadata
|
|
35
|
+
|
|
36
|
+
entry.update(feature.get("properties") or {})
|
|
37
|
+
entry["geometry"] = feature["geometry"]
|
|
38
|
+
content[title] = entry
|
|
39
|
+
|
|
40
|
+
return self._to_nodes({root_title: content})
|
|
41
|
+
|
|
42
|
+
def _to_nodes(self, data: dict, level: int = 1) -> list[Node]:
|
|
43
|
+
|
|
44
|
+
nodes: list[Node] = []
|
|
45
|
+
|
|
46
|
+
metadata = data.get(_METADATA_KEY) or {}
|
|
47
|
+
field_mapping = metadata.get(_FIELD_MAPPING) or {}
|
|
48
|
+
extra_fields = {name: None for name in metadata.get(_EXTRA_FIELDS) or []}
|
|
49
|
+
|
|
50
|
+
for key, value in data.items():
|
|
51
|
+
if key == _METADATA_KEY:
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
if key in extra_fields:
|
|
55
|
+
extra_fields[key] = value
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
nodes.append(Heading(
|
|
59
|
+
level=level,
|
|
60
|
+
children=[Text(raw=field_mapping.get(key, key))],
|
|
61
|
+
))
|
|
62
|
+
|
|
63
|
+
if isinstance(value, dict):
|
|
64
|
+
if key == "geometry":
|
|
65
|
+
nodes.append(GeometryBlock(geometry=value))
|
|
66
|
+
else:
|
|
67
|
+
nodes.extend(self._to_nodes(value, level + 1))
|
|
68
|
+
elif isinstance(value, list):
|
|
69
|
+
nodes.append(self._to_list_node(value))
|
|
70
|
+
else:
|
|
71
|
+
nodes.append(Paragraph(children=[Text(raw=str(value))]))
|
|
72
|
+
|
|
73
|
+
if metadata:
|
|
74
|
+
nodes.insert(0, HtmlBlock(
|
|
75
|
+
raw=render_metadata_as_html_comment({
|
|
76
|
+
_EXTRA_FIELDS: extra_fields,
|
|
77
|
+
_FIELD_MAPPING: {v: k for k, v in field_mapping.items()}
|
|
78
|
+
})
|
|
79
|
+
))
|
|
80
|
+
|
|
81
|
+
return nodes
|
|
82
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from json import JSONDecodeError
|
|
3
|
+
|
|
4
|
+
import geojson
|
|
5
|
+
|
|
6
|
+
from .markdown_parser import MarkdownParser
|
|
7
|
+
from .. import CodeBlock
|
|
8
|
+
from ..models import Node, GeometryBlock
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_GEOMETRY_LANGUAGE = "geometry"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GeoMarkdownParser(MarkdownParser):
|
|
18
|
+
"""
|
|
19
|
+
Markdown parser that upgrades ```geometry blocks into GeometryBlock nodes.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def parse(self, text: str) -> list[Node]:
|
|
23
|
+
nodes = super().parse(text)
|
|
24
|
+
return self._apply_geometry_block(nodes)
|
|
25
|
+
|
|
26
|
+
def _apply_geometry_block(self, nodes: list[Node]) -> list[Node]:
|
|
27
|
+
for i, node in enumerate(nodes):
|
|
28
|
+
if isinstance(node, CodeBlock) and node.language == _GEOMETRY_LANGUAGE:
|
|
29
|
+
try:
|
|
30
|
+
geometry = geojson.loads(node.raw)
|
|
31
|
+
except JSONDecodeError as e:
|
|
32
|
+
logger.warning("Failed to parse geometry block: %s; raw=%r", e, node.raw)
|
|
33
|
+
geometry = None
|
|
34
|
+
# Replace CodeBlock with GeometryBlock
|
|
35
|
+
nodes[i] = GeometryBlock(geometry=geometry)
|
|
36
|
+
return nodes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from geomd.models import Node, Heading, Text, Paragraph, Image, Link, ListItem, List
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class JsonParser:
|
|
11
|
+
"""Convert a JSON document into a flat list of document nodes."""
|
|
12
|
+
|
|
13
|
+
def parse(self, text: str) -> list[Node]:
|
|
14
|
+
data = json.loads(text)
|
|
15
|
+
return self._to_nodes(data)
|
|
16
|
+
|
|
17
|
+
def _to_nodes(self, data: dict, level: int = 1) -> list[Node]:
|
|
18
|
+
nodes: list[Node] = []
|
|
19
|
+
for key, value in data.items():
|
|
20
|
+
|
|
21
|
+
nodes.append(Heading(level=level, children=[Text(raw=key)]))
|
|
22
|
+
|
|
23
|
+
if isinstance(value, dict):
|
|
24
|
+
nodes.extend(self._to_nodes(value, level + 1))
|
|
25
|
+
|
|
26
|
+
elif isinstance(value, list):
|
|
27
|
+
nodes.append(self._to_list_node(value))
|
|
28
|
+
else:
|
|
29
|
+
nodes.append(Paragraph(children=[Text(raw=str(value))]))
|
|
30
|
+
|
|
31
|
+
return nodes
|
|
32
|
+
|
|
33
|
+
def _to_list_node(self, items: list) -> Node:
|
|
34
|
+
list_nodes: list[ListItem] = []
|
|
35
|
+
for item in items:
|
|
36
|
+
node = self._to_resource_node(item)
|
|
37
|
+
if node is not None:
|
|
38
|
+
list_nodes.append(ListItem(children=[node]))
|
|
39
|
+
return List(children=list_nodes)
|
|
40
|
+
|
|
41
|
+
def _to_resource_node(self, item: str | dict) -> Node | None:
|
|
42
|
+
if not isinstance(item, dict):
|
|
43
|
+
return Text(raw=str(item))
|
|
44
|
+
|
|
45
|
+
url = item.get("url")
|
|
46
|
+
title = item.get("title")
|
|
47
|
+
|
|
48
|
+
if not url and not title:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
if "alt" in item:
|
|
52
|
+
alt = item["alt"]
|
|
53
|
+
return Image(url=url, title=title, alt=alt)
|
|
54
|
+
|
|
55
|
+
return Link(url=url, title=title)
|
|
56
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import mistune
|
|
4
|
+
|
|
5
|
+
from geomd.models import Node, Heading, Paragraph, Text, CodeBlock, Link, Image, List, ListItem, HtmlBlock
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
_md = mistune.create_markdown(renderer=None)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MarkdownParser:
|
|
13
|
+
"""Convert a Markdown document into a flat list of document nodes."""
|
|
14
|
+
|
|
15
|
+
def parse(self, text: str) -> list[Node]:
|
|
16
|
+
ast, _ = _md.parse(text)
|
|
17
|
+
nodes = []
|
|
18
|
+
for node in ast:
|
|
19
|
+
node = self._to_nodes(node)
|
|
20
|
+
if node is not None:
|
|
21
|
+
nodes.append(node)
|
|
22
|
+
return nodes
|
|
23
|
+
|
|
24
|
+
def _to_nodes(self, node: dict) -> Node | None:
|
|
25
|
+
node_type = node["type"]
|
|
26
|
+
match node_type:
|
|
27
|
+
case "heading":
|
|
28
|
+
return Heading(
|
|
29
|
+
level=node["attrs"]["level"],
|
|
30
|
+
children=self._get_children(node)
|
|
31
|
+
)
|
|
32
|
+
case "paragraph" | "block_text":
|
|
33
|
+
return Paragraph(
|
|
34
|
+
children=self._get_children(node)
|
|
35
|
+
)
|
|
36
|
+
case "text":
|
|
37
|
+
return Text(raw=node["raw"])
|
|
38
|
+
case "block_code":
|
|
39
|
+
return CodeBlock(
|
|
40
|
+
language=node.get("attrs", {}).get("info", ""),
|
|
41
|
+
raw=node["raw"]
|
|
42
|
+
)
|
|
43
|
+
case "link":
|
|
44
|
+
title = ""
|
|
45
|
+
children = node.get("children")
|
|
46
|
+
if children:
|
|
47
|
+
title = children[0].get("raw")
|
|
48
|
+
return Link(
|
|
49
|
+
url=node["attrs"]["url"],
|
|
50
|
+
title=title,
|
|
51
|
+
)
|
|
52
|
+
case "image":
|
|
53
|
+
title = ""
|
|
54
|
+
children = node.get("children")
|
|
55
|
+
if children:
|
|
56
|
+
title = children[0].get("raw")
|
|
57
|
+
return Image(
|
|
58
|
+
url=node["attrs"]["url"],
|
|
59
|
+
alt=node["attrs"].get("title") or "",
|
|
60
|
+
title=title
|
|
61
|
+
)
|
|
62
|
+
case "list":
|
|
63
|
+
return List(
|
|
64
|
+
ordered=node["bullet"] != "-",
|
|
65
|
+
children=self._get_children(node)
|
|
66
|
+
)
|
|
67
|
+
case "list_item":
|
|
68
|
+
return ListItem(
|
|
69
|
+
children=self._get_children(node)
|
|
70
|
+
)
|
|
71
|
+
case "block_html":
|
|
72
|
+
return HtmlBlock(raw=node.get("raw"))
|
|
73
|
+
case "blank_line":
|
|
74
|
+
return None
|
|
75
|
+
case _:
|
|
76
|
+
raise ValueError(f"unhandled token: {node_type}")
|
|
77
|
+
|
|
78
|
+
def _get_children(self, node: dict) -> list[Node]:
|
|
79
|
+
return [
|
|
80
|
+
node := self._to_nodes(child)
|
|
81
|
+
for child in node.get("children") or []
|
|
82
|
+
if node is not None
|
|
83
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from .models import Node
|
|
2
|
+
from .parsers.json_parser import JsonParser
|
|
3
|
+
from .parsers.markdown_parser import MarkdownParser
|
|
4
|
+
from .parsers.geojson_parser import GeoJsonParser
|
|
5
|
+
from .parsers.geomarkdown_parser import GeoMarkdownParser
|
|
6
|
+
from .protocol import Parser, Renderer
|
|
7
|
+
from .renderers.geomarkdown_renderer import GeoMarkdownRenderer
|
|
8
|
+
from .renderers.json_renderer import JsonRenderer
|
|
9
|
+
from .renderers.markdown_renderer import MarkdownRenderer
|
|
10
|
+
from .renderers.geojson_renderer import GeoJsonRenderer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"convert", "parse", "render"
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
PARSERS: dict[str, Parser] = {
|
|
18
|
+
"json": JsonParser(),
|
|
19
|
+
"md": MarkdownParser(),
|
|
20
|
+
"geomd": GeoMarkdownParser(),
|
|
21
|
+
"geojson": GeoJsonParser()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
RENDERERS: dict[str, Renderer] = {
|
|
26
|
+
"json": JsonRenderer(),
|
|
27
|
+
"md": MarkdownRenderer(),
|
|
28
|
+
"geojson": GeoJsonRenderer(),
|
|
29
|
+
"geomd": GeoMarkdownRenderer()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def convert(text: str, src: str, dst: str) -> str:
|
|
34
|
+
return render(parse(text, src), dst)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse(text: str, fmt: str) -> list[Node]:
|
|
38
|
+
return PARSERS[fmt].parse(text)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render(nodes: list[Node], fmt: str) -> str:
|
|
42
|
+
return RENDERERS[fmt].render(nodes)
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import geojson
|
|
2
|
+
|
|
3
|
+
from .json_renderer import JsonRenderer
|
|
4
|
+
from ..models import Node, List, Paragraph, GeometryBlock, HtmlBlock
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GeoJsonRenderer(JsonRenderer):
|
|
8
|
+
"""Render document nodes to a GeoJSON FeatureCollection string."""
|
|
9
|
+
|
|
10
|
+
def render(self, nodes: list[Node]) -> str:
|
|
11
|
+
json_dict = super()._render_json(nodes)
|
|
12
|
+
formatted_dict = self._map_fields_and_add_extra_fields(json_dict)
|
|
13
|
+
features, extra = self._build_features(formatted_dict, )
|
|
14
|
+
feature_collection = geojson.FeatureCollection(features=features, **extra)
|
|
15
|
+
return geojson.dumps(feature_collection)
|
|
16
|
+
|
|
17
|
+
def _build_features(
|
|
18
|
+
self,
|
|
19
|
+
data: dict,
|
|
20
|
+
title: str | None = None
|
|
21
|
+
) -> tuple[list[geojson.Feature], dict]:
|
|
22
|
+
"""Recursively split data into Features (dicts with geometry) and extra properties."""
|
|
23
|
+
|
|
24
|
+
# A dict containing "geometry" becomes one Feature,
|
|
25
|
+
# everything else bubbles up
|
|
26
|
+
if "geometry" in data:
|
|
27
|
+
geometry = data.pop("geometry")
|
|
28
|
+
return [geojson.Feature(
|
|
29
|
+
geometry=geometry,
|
|
30
|
+
properties=data,
|
|
31
|
+
title=title
|
|
32
|
+
)], {}
|
|
33
|
+
|
|
34
|
+
extra: dict = {}
|
|
35
|
+
features: list[geojson.Feature] = list()
|
|
36
|
+
|
|
37
|
+
for key, value in data.items():
|
|
38
|
+
if isinstance(value, dict):
|
|
39
|
+
feats, ext = self._build_features(
|
|
40
|
+
data=value,
|
|
41
|
+
title=key
|
|
42
|
+
)
|
|
43
|
+
features.extend(feats)
|
|
44
|
+
extra.update(ext)
|
|
45
|
+
else:
|
|
46
|
+
extra[key] = value
|
|
47
|
+
|
|
48
|
+
return features, extra
|
|
49
|
+
|
|
50
|
+
def _render_section(self, nodes: list[Node]) -> list | dict | str:
|
|
51
|
+
"""Render a section of nodes."""
|
|
52
|
+
|
|
53
|
+
# Fast path: a single List / GeometryBlock / HtmlBlock maps to a non-paragraph value
|
|
54
|
+
if len(nodes) == 1:
|
|
55
|
+
node = nodes[0]
|
|
56
|
+
if isinstance(node, List):
|
|
57
|
+
return self._render_list_block(nodes[0].children)
|
|
58
|
+
elif isinstance(node, GeometryBlock):
|
|
59
|
+
return node.geometry
|
|
60
|
+
elif isinstance(node, HtmlBlock):
|
|
61
|
+
return node.raw
|
|
62
|
+
|
|
63
|
+
# General path: render each supported node to a string, join with blank lines
|
|
64
|
+
result: list[str] = list()
|
|
65
|
+
for node in nodes:
|
|
66
|
+
if isinstance(node, List):
|
|
67
|
+
result.append(self._render_inline_list(node.children, ordered=node.ordered))
|
|
68
|
+
elif isinstance(node, Paragraph):
|
|
69
|
+
result.append(self._render_paragraph(node.children))
|
|
70
|
+
return "\n\n".join(result)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import geojson
|
|
2
|
+
|
|
3
|
+
from .markdown_renderer import MarkdownRenderer
|
|
4
|
+
from ..models import Node, Heading, Paragraph, List, CodeBlock, GeometryBlock, HtmlBlock
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GeoMarkdownRenderer(MarkdownRenderer):
|
|
8
|
+
"""Render document nodes back to Markdown text."""
|
|
9
|
+
|
|
10
|
+
def render(self, nodes: list[Node]) -> str:
|
|
11
|
+
return self._to_markdown(nodes)
|
|
12
|
+
|
|
13
|
+
def _to_markdown(self, nodes: list[Node]) -> str:
|
|
14
|
+
"""Convert each block node to its Markdown form, joined by blank lines."""
|
|
15
|
+
|
|
16
|
+
blocks: list[str] = []
|
|
17
|
+
for node in nodes:
|
|
18
|
+
if isinstance(node, Heading):
|
|
19
|
+
blocks.append("#" * node.level + " " + self._render_inline(node.children))
|
|
20
|
+
elif isinstance(node, Paragraph):
|
|
21
|
+
blocks.append(self._render_inline(node.children))
|
|
22
|
+
elif isinstance(node, List):
|
|
23
|
+
blocks.append(self._render_list(node.children, ordered=node.ordered))
|
|
24
|
+
elif isinstance(node, CodeBlock):
|
|
25
|
+
language = node.language or ""
|
|
26
|
+
blocks.append(f"```{language}\n{node.raw}\n```")
|
|
27
|
+
elif isinstance(node, HtmlBlock):
|
|
28
|
+
blocks.append(f"{node.raw}")
|
|
29
|
+
elif isinstance(node, GeometryBlock):
|
|
30
|
+
blocks.append(f"```geometry\n{geojson.dumps(node.geometry)}\n```")
|
|
31
|
+
else:
|
|
32
|
+
raise ValueError(f"No such block node: {node.NODE_TYPE}")
|
|
33
|
+
return "\n\n".join(blocks) + "\n"
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from ..helpers import parse_metadata_from_html_comment
|
|
4
|
+
from ..models import Node, Heading, Paragraph, Text, List, Link, Image, ListItem, HtmlBlock
|
|
5
|
+
from ..constants import _METADATA_KEY, _FIELD_MAPPING, _EXTRA_FIELDS
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JsonRenderer:
|
|
9
|
+
"""Render document nodes to a JSON string."""
|
|
10
|
+
|
|
11
|
+
def render(self, nodes: list[Node]) -> str:
|
|
12
|
+
return self._to_json(nodes)
|
|
13
|
+
|
|
14
|
+
def _to_json(self, nodes: list[Node]) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Render nodes, then apply field mapping and
|
|
17
|
+
extra fields from metadata.
|
|
18
|
+
"""
|
|
19
|
+
json_dict = self._render_json(nodes)
|
|
20
|
+
formatted_json_dict = self._map_fields_and_add_extra_fields(json_dict)
|
|
21
|
+
return json.dumps(formatted_json_dict)
|
|
22
|
+
|
|
23
|
+
def _map_fields_and_add_extra_fields(self, data: dict) -> dict:
|
|
24
|
+
if not isinstance(data, dict):
|
|
25
|
+
return data
|
|
26
|
+
|
|
27
|
+
# Extract metadata up front so mapping applies to *all* sibling
|
|
28
|
+
# keys, regardless of where "_metadata" sits in the dict
|
|
29
|
+
metadata = data.get(_METADATA_KEY) or {}
|
|
30
|
+
extra_fields = metadata.get(_EXTRA_FIELDS) or {}
|
|
31
|
+
field_mapping = metadata.get(_FIELD_MAPPING) or {}
|
|
32
|
+
|
|
33
|
+
result: dict = {}
|
|
34
|
+
|
|
35
|
+
# Merge extras first so they go before real (mapped) content
|
|
36
|
+
result.update(extra_fields)
|
|
37
|
+
|
|
38
|
+
for key, value in data.items():
|
|
39
|
+
if key == _METADATA_KEY:
|
|
40
|
+
continue
|
|
41
|
+
result[field_mapping.get(key, key)] = self._map_fields_and_add_extra_fields(value)
|
|
42
|
+
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
def _render_json(self, nodes: list[Node]) -> dict:
|
|
46
|
+
"""Convert a node list to a nested dict keyed by heading text.
|
|
47
|
+
|
|
48
|
+
Example input:
|
|
49
|
+
|
|
50
|
+
# Header 3
|
|
51
|
+
Some text
|
|
52
|
+
## Header 4
|
|
53
|
+
Content under header 1
|
|
54
|
+
|
|
55
|
+
Note: Some text will be lost in the current implementation
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
result: dict = {}
|
|
59
|
+
i = 0
|
|
60
|
+
|
|
61
|
+
while i < len(nodes):
|
|
62
|
+
node = nodes[i]
|
|
63
|
+
if not isinstance(node, Heading):
|
|
64
|
+
i += 1
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Key is a property name
|
|
68
|
+
key = self._render_paragraph(node.children)
|
|
69
|
+
|
|
70
|
+
# Collect everything until the next heading of same/down level
|
|
71
|
+
j = i + 1
|
|
72
|
+
while j < len(nodes):
|
|
73
|
+
next_node = nodes[j]
|
|
74
|
+
if isinstance(next_node, Heading) and next_node.level <= node.level:
|
|
75
|
+
# Found next same/up level
|
|
76
|
+
break
|
|
77
|
+
j += 1
|
|
78
|
+
|
|
79
|
+
# Current level section content
|
|
80
|
+
section = nodes[i + 1:j]
|
|
81
|
+
section_value: dict = {}
|
|
82
|
+
|
|
83
|
+
# Pull a metadata HTML comment from the section head, if present
|
|
84
|
+
# (only when it's not the entire section. Otherwise, it's the content)
|
|
85
|
+
if len(section) > 1:
|
|
86
|
+
first_section_node = section[0]
|
|
87
|
+
if isinstance(first_section_node, HtmlBlock):
|
|
88
|
+
section_value[_METADATA_KEY] = parse_metadata_from_html_comment(first_section_node.raw)
|
|
89
|
+
section = section[1:]
|
|
90
|
+
|
|
91
|
+
if any(isinstance(_node, Heading) for _node in section):
|
|
92
|
+
# Go deeper if there is Heading
|
|
93
|
+
# Note: We assume that next node is Header, otherwise
|
|
94
|
+
# we lose data
|
|
95
|
+
section_value.update(self._render_json(section))
|
|
96
|
+
result[key] = section_value
|
|
97
|
+
else:
|
|
98
|
+
values = self._render_section(section)
|
|
99
|
+
if not values:
|
|
100
|
+
result[key] = ""
|
|
101
|
+
elif len(values) == 1:
|
|
102
|
+
result[key] = values[0]
|
|
103
|
+
else:
|
|
104
|
+
result[key] = values
|
|
105
|
+
|
|
106
|
+
i = j
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
def _render_section(self, nodes: list[Node]) -> list | str:
|
|
110
|
+
"""Render a section's content."""
|
|
111
|
+
if len(nodes) == 1:
|
|
112
|
+
node = nodes[0]
|
|
113
|
+
if isinstance(node, List):
|
|
114
|
+
return self._render_list_block(node.children)
|
|
115
|
+
elif isinstance(node, HtmlBlock):
|
|
116
|
+
return node.raw
|
|
117
|
+
|
|
118
|
+
# General case: paragraphs and inline lists joined by blank lines
|
|
119
|
+
result: list[str] = []
|
|
120
|
+
for node in nodes:
|
|
121
|
+
if isinstance(node, List):
|
|
122
|
+
result.append(self._render_inline_list(node.children, ordered=node.ordered))
|
|
123
|
+
elif isinstance(node, Paragraph):
|
|
124
|
+
result.append(self._render_paragraph(node.children))
|
|
125
|
+
return "\n\n".join(result)
|
|
126
|
+
|
|
127
|
+
def _render_list_block(self, nodes: list[Node]) -> list:
|
|
128
|
+
"""Convert list items to resource dicts or plain strings."""
|
|
129
|
+
result: list = []
|
|
130
|
+
for node in nodes:
|
|
131
|
+
if not node.children:
|
|
132
|
+
continue
|
|
133
|
+
result.append(self._render_resource(node.children[0]))
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
def _render_resource(self, node: Node) -> dict | str | None:
|
|
137
|
+
"""Convert a Link/Image/Text/Paragraph node"""
|
|
138
|
+
if isinstance(node, Link):
|
|
139
|
+
return {
|
|
140
|
+
"url": node.url,
|
|
141
|
+
"title": node.title
|
|
142
|
+
}
|
|
143
|
+
elif isinstance(node, Image):
|
|
144
|
+
return {
|
|
145
|
+
"url": node.url,
|
|
146
|
+
"title": node.title,
|
|
147
|
+
"alt": node.alt
|
|
148
|
+
}
|
|
149
|
+
elif isinstance(node, Text):
|
|
150
|
+
return node.raw
|
|
151
|
+
elif isinstance(node, Paragraph):
|
|
152
|
+
return self._render_resource(node.children[0])
|
|
153
|
+
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
def _render_inline_list(
|
|
157
|
+
self,
|
|
158
|
+
nodes: list[ListItem],
|
|
159
|
+
ordered: bool = False,
|
|
160
|
+
indent: int = 0
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Render list items as Markdown bullets / numbered lines."""
|
|
163
|
+
result: list[str] = []
|
|
164
|
+
for i, node in enumerate(nodes):
|
|
165
|
+
marker = f"{i + 1}. " if ordered else "- "
|
|
166
|
+
pad = " " * indent
|
|
167
|
+
for child in node.children:
|
|
168
|
+
content = []
|
|
169
|
+
if isinstance(child, Paragraph):
|
|
170
|
+
content.append(self._render_paragraph(child.children))
|
|
171
|
+
result.append(pad + marker + "\n".join(content))
|
|
172
|
+
return "\n".join(result)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _render_paragraph(self, nodes: list[Node]) -> str:
|
|
176
|
+
"""Flatten inline nodes to plain text (keeps links/images, drops other formatting)."""
|
|
177
|
+
result: list[str] = []
|
|
178
|
+
for node in nodes:
|
|
179
|
+
if isinstance(node, Text):
|
|
180
|
+
result.append(node.raw)
|
|
181
|
+
if isinstance(node, Link):
|
|
182
|
+
title = f'"{node.title}"' if node.title else ""
|
|
183
|
+
result.append(f"[{title}]({node.url})")
|
|
184
|
+
elif isinstance(node, Image):
|
|
185
|
+
# Standard Markdown: 
|
|
186
|
+
title = f' "{node.title}"' if node.title else ""
|
|
187
|
+
result.append(f"")
|
|
188
|
+
else:
|
|
189
|
+
result.append(self._render_paragraph(getattr(node, "children", [])))
|
|
190
|
+
return "".join(result)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from geomd.models import Node, Heading, Paragraph, CodeBlock, Text, Link, Image, List, ListItem
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MarkdownRenderer:
|
|
5
|
+
"""Render document nodes back to Markdown text."""
|
|
6
|
+
|
|
7
|
+
def render(self, nodes: list[Node]) -> str:
|
|
8
|
+
return self._to_markdown(nodes)
|
|
9
|
+
|
|
10
|
+
def _to_markdown(self, nodes: list[Node]) -> str:
|
|
11
|
+
"""Convert each block node to its Markdown form, joined by blank lines."""
|
|
12
|
+
|
|
13
|
+
blocks: list[str] = []
|
|
14
|
+
for node in nodes:
|
|
15
|
+
if isinstance(node, Heading):
|
|
16
|
+
blocks.append("#" * node.level + " " + self._render_inline(node.children))
|
|
17
|
+
elif isinstance(node, Paragraph):
|
|
18
|
+
blocks.append(self._render_inline(node.children))
|
|
19
|
+
elif isinstance(node, List):
|
|
20
|
+
blocks.append(self._render_list(node.children, ordered=node.ordered))
|
|
21
|
+
elif isinstance(node, CodeBlock):
|
|
22
|
+
language = node.language or ""
|
|
23
|
+
blocks.append(f"```{language}\n{node.raw}\n```")
|
|
24
|
+
else:
|
|
25
|
+
raise ValueError(f"No such block node: {node.NODE_TYPE}")
|
|
26
|
+
return "\n\n".join(blocks) + "\n"
|
|
27
|
+
|
|
28
|
+
def _render_list(
|
|
29
|
+
self, nodes:
|
|
30
|
+
list[Node],
|
|
31
|
+
ordered: bool = False,
|
|
32
|
+
indent: int = 0
|
|
33
|
+
) -> str:
|
|
34
|
+
"""Render list items as Markdown bullets or numbered entries."""
|
|
35
|
+
lines: list[str] = []
|
|
36
|
+
pad = " " * indent
|
|
37
|
+
|
|
38
|
+
for i, item in enumerate(nodes):
|
|
39
|
+
marker = f"{i + 1}. " if ordered else "- "
|
|
40
|
+
body = self._render_inline(item.children).strip()
|
|
41
|
+
body_lines = body.splitlines() or [""]
|
|
42
|
+
|
|
43
|
+
# First line carries the marker; continuation lines are indented
|
|
44
|
+
# to line up with the item's text
|
|
45
|
+
lines.append(pad + marker + body_lines[0])
|
|
46
|
+
continuation = " " * len(marker)
|
|
47
|
+
for line in body_lines[1:]:
|
|
48
|
+
lines.append(pad + continuation + line)
|
|
49
|
+
|
|
50
|
+
return "\n".join(lines)
|
|
51
|
+
|
|
52
|
+
def _render_inline(self, nodes: list[Node]) -> str:
|
|
53
|
+
"""Flatten inline nodes (Text/Link/Image) to a single string."""
|
|
54
|
+
|
|
55
|
+
result: list[str] = []
|
|
56
|
+
for node in nodes:
|
|
57
|
+
if isinstance(node, Text):
|
|
58
|
+
result.append(node.raw)
|
|
59
|
+
elif isinstance(node, Link):
|
|
60
|
+
title = f'"{node.title}"' if node.title else ""
|
|
61
|
+
result.append(f"[{title}]({node.url})")
|
|
62
|
+
elif isinstance(node, Image):
|
|
63
|
+
# Standard Markdown: 
|
|
64
|
+
title = f' "{node.title}"' if node.title else ""
|
|
65
|
+
result.append(f"")
|
|
66
|
+
else:
|
|
67
|
+
raise ValueError(f"No such inline node: {node.NODE_TYPE}")
|
|
68
|
+
return "".join(result)
|