bootstrap-python 0.1.0__py3-none-any.whl
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.
- bootstrap_python-0.1.0.dist-info/METADATA +226 -0
- bootstrap_python-0.1.0.dist-info/RECORD +15 -0
- bootstrap_python-0.1.0.dist-info/WHEEL +5 -0
- bootstrap_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- bootstrap_python-0.1.0.dist-info/top_level.txt +1 -0
- bootstrap_ui/__init__.py +162 -0
- bootstrap_ui/constants.py +85 -0
- bootstrap_ui/content.py +562 -0
- bootstrap_ui/data.py +411 -0
- bootstrap_ui/forms.py +489 -0
- bootstrap_ui/layout.py +260 -0
- bootstrap_ui/nav.py +356 -0
- bootstrap_ui/overlays.py +199 -0
- bootstrap_ui/templates/page.jinja2 +37 -0
- bootstrap_ui/types.py +236 -0
bootstrap_ui/overlays.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""浮层组件:Modal / Offcanvas / Toast / ToastContainer / Tooltip。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, ClassVar, Literal
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
from .constants import Color, value as enum_value
|
|
11
|
+
from .content import icon_name
|
|
12
|
+
from .types import Node, NodeLike, escape, render_attrs, render_children
|
|
13
|
+
|
|
14
|
+
__all__ = ["Modal", "Offcanvas", "Toast", "ToastContainer", "Tooltip"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Modal(Node):
|
|
18
|
+
"""模态对话框。用 ``Button(toggle_modal="你的id")`` 打开。"""
|
|
19
|
+
|
|
20
|
+
id: str = Field(default_factory=lambda: f"modal-{uuid4().hex[:6]}")
|
|
21
|
+
title: str | None = None
|
|
22
|
+
body: list[NodeLike] = Field(default_factory=list)
|
|
23
|
+
footer: list[NodeLike] | None = None
|
|
24
|
+
size: Literal["sm", "lg", "xl"] | None = None
|
|
25
|
+
fullscreen: bool = False
|
|
26
|
+
centered: bool = True
|
|
27
|
+
scrollable: bool = False
|
|
28
|
+
static_backdrop: bool = False
|
|
29
|
+
close_button: bool = True
|
|
30
|
+
|
|
31
|
+
def css_classes(self) -> list[str]:
|
|
32
|
+
return ["modal", "fade"]
|
|
33
|
+
|
|
34
|
+
def html_attrs(self) -> dict[str, Any]:
|
|
35
|
+
attrs = super().html_attrs()
|
|
36
|
+
attrs.setdefault("tabindex", "-1")
|
|
37
|
+
attrs.setdefault("aria-hidden", "true")
|
|
38
|
+
if self.title:
|
|
39
|
+
attrs.setdefault("aria-labelledby", f"{self.id}-label")
|
|
40
|
+
if self.static_backdrop:
|
|
41
|
+
attrs["data-bs-backdrop"] = "static"
|
|
42
|
+
attrs["data-bs-keyboard"] = "false"
|
|
43
|
+
return attrs
|
|
44
|
+
|
|
45
|
+
def render_content(self) -> str:
|
|
46
|
+
dialog_classes = ["modal-dialog"]
|
|
47
|
+
if self.size:
|
|
48
|
+
dialog_classes.append(f"modal-{self.size}")
|
|
49
|
+
if self.centered:
|
|
50
|
+
dialog_classes.append("modal-dialog-centered")
|
|
51
|
+
if self.scrollable:
|
|
52
|
+
dialog_classes.append("modal-dialog-scrollable")
|
|
53
|
+
if self.fullscreen:
|
|
54
|
+
dialog_classes.append("modal-fullscreen")
|
|
55
|
+
|
|
56
|
+
header = ""
|
|
57
|
+
if self.title or self.close_button:
|
|
58
|
+
title = (
|
|
59
|
+
f'<h5 class="modal-title" id="{self.id}-label">{escape(self.title)}</h5>'
|
|
60
|
+
if self.title
|
|
61
|
+
else ""
|
|
62
|
+
)
|
|
63
|
+
close = (
|
|
64
|
+
'<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="关闭"></button>'
|
|
65
|
+
if self.close_button
|
|
66
|
+
else ""
|
|
67
|
+
)
|
|
68
|
+
header = f'<div class="modal-header">{title}{close}</div>'
|
|
69
|
+
|
|
70
|
+
footer = ""
|
|
71
|
+
if self.footer:
|
|
72
|
+
footer = f'<div class="modal-footer">{render_children(self.footer)}</div>'
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
f'<div class="{" ".join(dialog_classes)}">'
|
|
76
|
+
f'<div class="modal-content">{header}'
|
|
77
|
+
f'<div class="modal-body">{render_children(self.body)}</div>'
|
|
78
|
+
f"{footer}</div></div>"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Offcanvas(Node):
|
|
83
|
+
"""侧边抽屉。用 ``Button(toggle_offcanvas="你的id")`` 打开。"""
|
|
84
|
+
|
|
85
|
+
id: str = Field(default_factory=lambda: f"offcanvas-{uuid4().hex[:6]}")
|
|
86
|
+
title: str | None = None
|
|
87
|
+
body: list[NodeLike] = Field(default_factory=list)
|
|
88
|
+
footer: list[NodeLike] | None = None
|
|
89
|
+
placement: Literal["start", "end", "top", "bottom"] = "start"
|
|
90
|
+
close_button: bool = True
|
|
91
|
+
static_backdrop: bool = False
|
|
92
|
+
|
|
93
|
+
def css_classes(self) -> list[str]:
|
|
94
|
+
return ["offcanvas", f"offcanvas-{self.placement}"]
|
|
95
|
+
|
|
96
|
+
def html_attrs(self) -> dict[str, Any]:
|
|
97
|
+
attrs = super().html_attrs()
|
|
98
|
+
attrs.setdefault("tabindex", "-1")
|
|
99
|
+
attrs.setdefault("aria-labelledby", f"{self.id}-label")
|
|
100
|
+
if self.static_backdrop:
|
|
101
|
+
attrs["data-bs-backdrop"] = "static"
|
|
102
|
+
attrs["data-bs-keyboard"] = "false"
|
|
103
|
+
return attrs
|
|
104
|
+
|
|
105
|
+
def render_content(self) -> str:
|
|
106
|
+
header = ""
|
|
107
|
+
if self.title or self.close_button:
|
|
108
|
+
title = (
|
|
109
|
+
f'<h5 class="offcanvas-title" id="{self.id}-label">{escape(self.title)}</h5>'
|
|
110
|
+
if self.title
|
|
111
|
+
else ""
|
|
112
|
+
)
|
|
113
|
+
close = (
|
|
114
|
+
'<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="关闭"></button>'
|
|
115
|
+
if self.close_button
|
|
116
|
+
else ""
|
|
117
|
+
)
|
|
118
|
+
header = f'<div class="offcanvas-header">{title}{close}</div>'
|
|
119
|
+
footer = (
|
|
120
|
+
f'<div class="offcanvas-footer p-3 border-top">{render_children(self.footer)}</div>'
|
|
121
|
+
if self.footer
|
|
122
|
+
else ""
|
|
123
|
+
)
|
|
124
|
+
return f'{header}<div class="offcanvas-body">{render_children(self.body)}</div>{footer}'
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Toast(Node):
|
|
128
|
+
"""轻提示。页面脚本加载后会自动调用 ``show()``。"""
|
|
129
|
+
|
|
130
|
+
id: str = Field(default_factory=lambda: f"toast-{uuid4().hex[:6]}")
|
|
131
|
+
title: str | None = None
|
|
132
|
+
body: list[NodeLike] = Field(default_factory=list)
|
|
133
|
+
color: Color | None = None
|
|
134
|
+
icon: str | None = None
|
|
135
|
+
delay: int = 4000
|
|
136
|
+
autohide: bool = True
|
|
137
|
+
close_button: bool = True
|
|
138
|
+
|
|
139
|
+
def css_classes(self) -> list[str]:
|
|
140
|
+
classes = ["toast"]
|
|
141
|
+
if self.color:
|
|
142
|
+
classes.append(f"text-bg-{enum_value(self.color)}")
|
|
143
|
+
return classes
|
|
144
|
+
|
|
145
|
+
def html_attrs(self) -> dict[str, Any]:
|
|
146
|
+
attrs = super().html_attrs()
|
|
147
|
+
attrs.setdefault("role", "alert")
|
|
148
|
+
attrs.setdefault("aria-live", "assertive")
|
|
149
|
+
attrs.setdefault("aria-atomic", "true")
|
|
150
|
+
attrs["data-bs-delay"] = self.delay
|
|
151
|
+
attrs["data-bs-autohide"] = "true" if self.autohide else "false"
|
|
152
|
+
return attrs
|
|
153
|
+
|
|
154
|
+
def render_content(self) -> str:
|
|
155
|
+
parts: list[str] = []
|
|
156
|
+
if self.icon:
|
|
157
|
+
parts.append(f'<i class="{icon_name(self.icon)} me-2"></i>')
|
|
158
|
+
if self.title:
|
|
159
|
+
parts.append(f'<strong class="d-block mb-1">{escape(self.title)}</strong>')
|
|
160
|
+
parts.append(render_children(self.body))
|
|
161
|
+
close = ""
|
|
162
|
+
if self.close_button:
|
|
163
|
+
close = (
|
|
164
|
+
'<button type="button" class="btn-close btn-close-white me-2 m-auto" '
|
|
165
|
+
'data-bs-dismiss="toast" aria-label="关闭"></button>'
|
|
166
|
+
)
|
|
167
|
+
return f'<div class="d-flex"><div class="toast-body">{"".join(parts)}</div>{close}</div>'
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class ToastContainer(Node):
|
|
171
|
+
"""Toast 的固定定位容器。"""
|
|
172
|
+
|
|
173
|
+
children: list[NodeLike] = Field(default_factory=list)
|
|
174
|
+
placement: Literal["top-start", "top-end", "bottom-start", "bottom-end"] = "bottom-end"
|
|
175
|
+
|
|
176
|
+
def css_classes(self) -> list[str]:
|
|
177
|
+
return ["toast-container", "position-fixed", "p-3", self.placement]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class Tooltip(Node):
|
|
181
|
+
"""带悬停提示的行内元素。"""
|
|
182
|
+
|
|
183
|
+
TAG: ClassVar[str | None] = "span"
|
|
184
|
+
|
|
185
|
+
text: str = ""
|
|
186
|
+
content: str = ""
|
|
187
|
+
placement: Literal["top", "bottom", "start", "end"] = "top"
|
|
188
|
+
icon: str | None = None
|
|
189
|
+
|
|
190
|
+
def html_attrs(self) -> dict[str, Any]:
|
|
191
|
+
attrs = super().html_attrs()
|
|
192
|
+
attrs["data-bs-toggle"] = "tooltip"
|
|
193
|
+
attrs["data-bs-placement"] = self.placement
|
|
194
|
+
attrs["title"] = self.content
|
|
195
|
+
return attrs
|
|
196
|
+
|
|
197
|
+
def render_content(self) -> str:
|
|
198
|
+
icon = f'<i class="{icon_name(self.icon)} me-1"></i>' if self.icon else ""
|
|
199
|
+
return icon + escape(self.text)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="{{ lang }}"{% if theme %} data-bs-theme="{{ theme }}"{% endif %}>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{ title }}</title>
|
|
7
|
+
{% if icons %}<link rel="stylesheet" href="{{ cdn }}/bootstrap-icons@{{ icons_version }}/font/bootstrap-icons.min.css">
|
|
8
|
+
{% endif %}<link rel="stylesheet" href="{{ cdn }}/bootstrap@{{ version }}/dist/css/bootstrap.min.css">
|
|
9
|
+
{% for href in css %} <link rel="stylesheet" href="{{ href }}">
|
|
10
|
+
{% endfor %}{% if page_css %} <style>
|
|
11
|
+
{{ page_css }}
|
|
12
|
+
</style>
|
|
13
|
+
{% endif %}{% if head %}{{ head }}
|
|
14
|
+
{% endif %}</head>
|
|
15
|
+
<body{% if body_class %} class="{{ body_class }}"{% endif %}>
|
|
16
|
+
{{ navbar }}
|
|
17
|
+
{% if container %}<div class="{{ container_class }} py-4">
|
|
18
|
+
{% if toolbar %}<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-4">{{ toolbar }}</div>
|
|
19
|
+
{% endif %}{{ body }}
|
|
20
|
+
</div>
|
|
21
|
+
{% else %}{{ body }}
|
|
22
|
+
{% endif %}{{ footer }}
|
|
23
|
+
<script src="{{ cdn }}/bootstrap@{{ version }}/dist/js/bootstrap.bundle.min.js"></script>
|
|
24
|
+
{% for src in js %}<script src="{{ src }}"></script>
|
|
25
|
+
{% endfor %}<script>
|
|
26
|
+
// 初始化全部提示气泡
|
|
27
|
+
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function (el) {
|
|
28
|
+
new bootstrap.Tooltip(el);
|
|
29
|
+
});
|
|
30
|
+
// 页面加载后自动弹出 Toast
|
|
31
|
+
document.querySelectorAll('.toast').forEach(function (el) {
|
|
32
|
+
new bootstrap.Toast(el).show();
|
|
33
|
+
});
|
|
34
|
+
{{ page_js }}
|
|
35
|
+
</script>
|
|
36
|
+
</body>
|
|
37
|
+
</html>
|
bootstrap_ui/types.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""bootstrap-python 的渲染内核:Node 基类、属性序列化与 HTML 转义。
|
|
2
|
+
|
|
3
|
+
统一的输出约定:
|
|
4
|
+
|
|
5
|
+
node.to_dict() # 结构化描述(可 JSON 序列化,便于调试或传给前端)
|
|
6
|
+
node.to_json() # JSON 字符串
|
|
7
|
+
node.render() # HTML 字符串
|
|
8
|
+
|
|
9
|
+
设计要点
|
|
10
|
+
--------
|
|
11
|
+
1. 每个组件都是 ``Node`` 的子类,只描述「我是哪个标签、有哪些 Bootstrap 类、装什么内容」;
|
|
12
|
+
2. 文本内容默认转义,需要输出可信 HTML 时显式使用 :class:`RawHtml` / :func:`raw`;
|
|
13
|
+
3. 组件字段用 pydantic 校验(``extra="forbid"``),写错字段名会立刻报错,而不是静默失效。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import html as _html
|
|
19
|
+
import json
|
|
20
|
+
from typing import Any, ClassVar, Iterable, Sequence, Union
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Node",
|
|
26
|
+
"RawHtml",
|
|
27
|
+
"raw",
|
|
28
|
+
"NodeLike",
|
|
29
|
+
"escape",
|
|
30
|
+
"render_attrs",
|
|
31
|
+
"render_children",
|
|
32
|
+
"VOID_TAGS",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
#: HTML 中不需要闭合标签的元素
|
|
36
|
+
VOID_TAGS: frozenset[str] = frozenset(
|
|
37
|
+
{
|
|
38
|
+
"area",
|
|
39
|
+
"base",
|
|
40
|
+
"br",
|
|
41
|
+
"col",
|
|
42
|
+
"embed",
|
|
43
|
+
"hr",
|
|
44
|
+
"img",
|
|
45
|
+
"input",
|
|
46
|
+
"link",
|
|
47
|
+
"meta",
|
|
48
|
+
"param",
|
|
49
|
+
"source",
|
|
50
|
+
"track",
|
|
51
|
+
"wbr",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def escape(value: Any) -> str:
|
|
57
|
+
"""把任意值转成转义后的文本。"""
|
|
58
|
+
if value is None:
|
|
59
|
+
return ""
|
|
60
|
+
return _html.escape(str(value), quote=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def render_attrs(attrs: dict[str, Any]) -> str:
|
|
64
|
+
"""把属性字典渲染成 `` key="value"`` 形式的片段。
|
|
65
|
+
|
|
66
|
+
- ``None`` / ``False`` 直接跳过;
|
|
67
|
+
- ``True`` 输出为布尔属性(如 ``required``);
|
|
68
|
+
- ``list`` / ``tuple`` 以空格连接(常用于 class);
|
|
69
|
+
- ``dict`` 序列化为 JSON(用于 data-* 配置)。
|
|
70
|
+
"""
|
|
71
|
+
parts: list[str] = []
|
|
72
|
+
for key, value in attrs.items():
|
|
73
|
+
if value is None or value is False:
|
|
74
|
+
continue
|
|
75
|
+
name = str(key).rstrip("_")
|
|
76
|
+
if value is True:
|
|
77
|
+
parts.append(f" {name}")
|
|
78
|
+
continue
|
|
79
|
+
if isinstance(value, (list, tuple, set)):
|
|
80
|
+
value = " ".join(str(item) for item in value if item is not None)
|
|
81
|
+
elif isinstance(value, dict):
|
|
82
|
+
value = json.dumps(value, ensure_ascii=False)
|
|
83
|
+
parts.append(f' {name}="{escape(value)}"')
|
|
84
|
+
return "".join(parts)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_children(items: Any, separator: str = "") -> str:
|
|
88
|
+
"""渲染子节点序列,自动处理嵌套列表与文本转义。"""
|
|
89
|
+
if items is None:
|
|
90
|
+
return ""
|
|
91
|
+
if isinstance(items, (str, bytes)) or isinstance(items, Node):
|
|
92
|
+
items = [items]
|
|
93
|
+
if not isinstance(items, Iterable):
|
|
94
|
+
return escape(items)
|
|
95
|
+
|
|
96
|
+
rendered: list[str] = []
|
|
97
|
+
for item in items:
|
|
98
|
+
if item is None:
|
|
99
|
+
continue
|
|
100
|
+
if isinstance(item, Node): # RawHtml 也是 Node,会走各自的 render
|
|
101
|
+
rendered.append(item.render())
|
|
102
|
+
elif isinstance(item, (list, tuple)):
|
|
103
|
+
rendered.append(render_children(item, separator))
|
|
104
|
+
else:
|
|
105
|
+
rendered.append(escape(item))
|
|
106
|
+
return separator.join(part for part in rendered if part != "")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Node(BaseModel):
|
|
110
|
+
"""所有组件的基类。"""
|
|
111
|
+
|
|
112
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
113
|
+
|
|
114
|
+
#: 组件对应的 HTML 标签,``None`` 表示不产生包裹标签
|
|
115
|
+
TAG: ClassVar[str | None] = "div"
|
|
116
|
+
|
|
117
|
+
id: str | None = None
|
|
118
|
+
"""元素 id"""
|
|
119
|
+
|
|
120
|
+
class_name: str | None = Field(default=None, alias="class")
|
|
121
|
+
"""追加到元素上的自定义 class(Bootstrap 原生类名)"""
|
|
122
|
+
|
|
123
|
+
style: str | dict[str, Any] | None = None
|
|
124
|
+
"""行内样式,字符串或 ``{"min-width": "10rem"}`` 这样的字典"""
|
|
125
|
+
|
|
126
|
+
attrs: dict[str, Any] = Field(default_factory=dict)
|
|
127
|
+
"""其它原生 HTML 属性,例如 ``{"data-bs-toggle": "tooltip", "aria-label": "提示"}``"""
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------ 扩展点
|
|
130
|
+
def css_classes(self) -> list[str]:
|
|
131
|
+
"""返回该组件的 Bootstrap 类名列表,子类按需覆写。"""
|
|
132
|
+
return []
|
|
133
|
+
|
|
134
|
+
def html_attrs(self) -> dict[str, Any]:
|
|
135
|
+
"""返回最终写进标签的属性字典。"""
|
|
136
|
+
attrs: dict[str, Any] = {}
|
|
137
|
+
|
|
138
|
+
classes = [cls for cls in self.css_classes() if cls]
|
|
139
|
+
if self.class_name:
|
|
140
|
+
classes.append(self.class_name)
|
|
141
|
+
if classes:
|
|
142
|
+
attrs["class"] = " ".join(classes)
|
|
143
|
+
|
|
144
|
+
if self.id:
|
|
145
|
+
attrs["id"] = self.id
|
|
146
|
+
|
|
147
|
+
if self.style:
|
|
148
|
+
attrs["style"] = _style_to_string(self.style)
|
|
149
|
+
|
|
150
|
+
for key, value in self.attrs.items():
|
|
151
|
+
attrs[key] = value
|
|
152
|
+
return attrs
|
|
153
|
+
|
|
154
|
+
def tag_name(self) -> str | None:
|
|
155
|
+
"""返回实际使用的标签名,需要动态标签的组件(如 Heading)可覆写。"""
|
|
156
|
+
return self.TAG
|
|
157
|
+
|
|
158
|
+
def render_content(self) -> str:
|
|
159
|
+
"""返回标签内部的内容。"""
|
|
160
|
+
return ""
|
|
161
|
+
|
|
162
|
+
def render(self) -> str:
|
|
163
|
+
"""渲染成 HTML 字符串。"""
|
|
164
|
+
content = self.render_content()
|
|
165
|
+
tag = self.tag_name()
|
|
166
|
+
if tag is None:
|
|
167
|
+
return content
|
|
168
|
+
|
|
169
|
+
attrs = render_attrs(self.html_attrs())
|
|
170
|
+
if tag in VOID_TAGS:
|
|
171
|
+
return f"<{tag}{attrs}>"
|
|
172
|
+
return f"<{tag}{attrs}>{content}</{tag}>"
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------ 输出
|
|
175
|
+
def to_dict(self) -> dict[str, Any]:
|
|
176
|
+
"""结构化描述,便于调试或把界面定义传给前端。"""
|
|
177
|
+
data = self.model_dump(
|
|
178
|
+
by_alias=True,
|
|
179
|
+
exclude_none=True,
|
|
180
|
+
mode="json",
|
|
181
|
+
serialize_as_any=True, # 保留子类组件的全部字段,而不是退化成 Node
|
|
182
|
+
)
|
|
183
|
+
return _clean(data)
|
|
184
|
+
|
|
185
|
+
def to_json(self) -> str:
|
|
186
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
|
|
187
|
+
|
|
188
|
+
def __str__(self) -> str: # noqa: D105 - 方便 print(node)
|
|
189
|
+
return self.render()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _clean(data: Any) -> Any:
|
|
193
|
+
"""去掉序列化结果里的空字段(如 ``attrs: {}``),让 to_dict 更干净。"""
|
|
194
|
+
if isinstance(data, dict):
|
|
195
|
+
return {
|
|
196
|
+
key: _clean(item)
|
|
197
|
+
for key, item in data.items()
|
|
198
|
+
if not (key in {"attrs", "style"} and not item)
|
|
199
|
+
}
|
|
200
|
+
if isinstance(data, list):
|
|
201
|
+
return [_clean(item) for item in data]
|
|
202
|
+
return data
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _style_to_string(style: str | dict[str, Any]) -> str:
|
|
206
|
+
if isinstance(style, str):
|
|
207
|
+
return style
|
|
208
|
+
chunks: list[str] = []
|
|
209
|
+
for key, value in style.items():
|
|
210
|
+
if value is None:
|
|
211
|
+
continue
|
|
212
|
+
name = str(key).replace("_", "-")
|
|
213
|
+
chunks.append(f"{name}: {value}")
|
|
214
|
+
return "; ".join(chunks)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class RawHtml(Node):
|
|
218
|
+
"""原样输出的 HTML 片段(不再转义)。"""
|
|
219
|
+
|
|
220
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
221
|
+
|
|
222
|
+
TAG: ClassVar[str | None] = None
|
|
223
|
+
|
|
224
|
+
html: str = ""
|
|
225
|
+
|
|
226
|
+
def render(self) -> str:
|
|
227
|
+
return self.html
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def raw(source: str) -> RawHtml:
|
|
231
|
+
"""快捷构造 :class:`RawHtml`。"""
|
|
232
|
+
return RawHtml(html=source)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
#: 可以放进容器里的内容:组件、文本、数字
|
|
236
|
+
NodeLike = Union[Node, str, int, float]
|