html5cc 0.3.0__tar.gz → 0.4.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.
- {html5cc-0.3.0 → html5cc-0.4.0}/PKG-INFO +1 -1
- {html5cc-0.3.0 → html5cc-0.4.0}/src/html5/__init__.py +3 -0
- {html5cc-0.3.0 → html5cc-0.4.0}/src/html5/css.py +42 -0
- html5cc-0.4.0/src/html5/loader.py +141 -0
- {html5cc-0.3.0 → html5cc-0.4.0}/src/html5/version.py +1 -1
- {html5cc-0.3.0 → html5cc-0.4.0}/src/html5cc.egg-info/PKG-INFO +1 -1
- {html5cc-0.3.0 → html5cc-0.4.0}/src/html5cc.egg-info/SOURCES.txt +1 -0
- {html5cc-0.3.0 → html5cc-0.4.0}/tests/test_html5.py +141 -1
- {html5cc-0.3.0 → html5cc-0.4.0}/LICENSE +0 -0
- {html5cc-0.3.0 → html5cc-0.4.0}/README.md +0 -0
- {html5cc-0.3.0 → html5cc-0.4.0}/pyproject.toml +0 -0
- {html5cc-0.3.0 → html5cc-0.4.0}/setup.cfg +0 -0
- {html5cc-0.3.0 → html5cc-0.4.0}/src/html5/document.py +0 -0
- {html5cc-0.3.0 → html5cc-0.4.0}/src/html5/elements.py +0 -0
- {html5cc-0.3.0 → html5cc-0.4.0}/src/html5/js.py +0 -0
- {html5cc-0.3.0 → html5cc-0.4.0}/src/html5/writer.py +0 -0
- {html5cc-0.3.0 → html5cc-0.4.0}/src/html5cc.egg-info/dependency_links.txt +0 -0
- {html5cc-0.3.0 → html5cc-0.4.0}/src/html5cc.egg-info/requires.txt +0 -0
- {html5cc-0.3.0 → html5cc-0.4.0}/src/html5cc.egg-info/top_level.txt +0 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from .css import (
|
|
4
4
|
CSSAtRule,
|
|
5
5
|
CSSComment,
|
|
6
|
+
CSSCustomProperties,
|
|
6
7
|
CSSDeclaration,
|
|
7
8
|
CSSLink,
|
|
8
9
|
CSSImportRule,
|
|
@@ -21,12 +22,14 @@ from .css import (
|
|
|
21
22
|
GOOGLE_FONTS_STATIC_URL,
|
|
22
23
|
TAILWIND_PLAY_CDN_URL,
|
|
23
24
|
bootstrap5_stylesheet,
|
|
25
|
+
css_var,
|
|
24
26
|
google_fonts_assets,
|
|
25
27
|
google_fonts_url,
|
|
26
28
|
inline_style,
|
|
27
29
|
tailwind_script,
|
|
28
30
|
style_tag,
|
|
29
31
|
)
|
|
32
|
+
from .loader import css_loader, html_loader
|
|
30
33
|
from .document import Comment, Doctype, Element, HtmlDocument, Node, Raw, Text, comment, doctype, doctype_node, element, raw, text
|
|
31
34
|
from .elements import *
|
|
32
35
|
from .version import __version__
|
|
@@ -50,6 +50,38 @@ class CSSDeclaration(CSSNode):
|
|
|
50
50
|
return f"{_normalize_property_name(self.property_name)}: {self.value}{suffix};"
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def css_var(name: str) -> str:
|
|
54
|
+
"""Return a CSS var() reference for a custom property.
|
|
55
|
+
|
|
56
|
+
Example: ``css_var("--size-1")`` → ``"var(--size-1)"``
|
|
57
|
+
"""
|
|
58
|
+
return f"var({name})"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, init=False)
|
|
62
|
+
class CSSCustomProperties(CSSNode):
|
|
63
|
+
"""Render a block of CSS custom properties (variables) on a selector.
|
|
64
|
+
|
|
65
|
+
Pass a plain ``dict`` mapping ``--name`` strings to values. The selector
|
|
66
|
+
defaults to ``:root`` so the variables are available document-wide::
|
|
67
|
+
|
|
68
|
+
CSSCustomProperties({"--size-1": "0.25rem", "--color-brand": "#005fcc"})
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
selector: str
|
|
72
|
+
properties: tuple[tuple[str, Any], ...]
|
|
73
|
+
|
|
74
|
+
def __init__(self, props: Mapping[str, Any], selector: str = ":root") -> None:
|
|
75
|
+
object.__setattr__(self, "selector", selector)
|
|
76
|
+
object.__setattr__(self, "properties", tuple(props.items()))
|
|
77
|
+
|
|
78
|
+
def render(self) -> str:
|
|
79
|
+
if not self.properties:
|
|
80
|
+
return f"{self.selector} {{}}"
|
|
81
|
+
declarations = " ".join(f"{name}: {value};" for name, value in self.properties)
|
|
82
|
+
return f"{self.selector} {{ {declarations} }}"
|
|
83
|
+
|
|
84
|
+
|
|
53
85
|
def _coerce_declaration(value: CSSDeclaration | tuple[str, Any] | Mapping[str, Any]) -> CSSDeclaration:
|
|
54
86
|
if isinstance(value, CSSDeclaration):
|
|
55
87
|
return value
|
|
@@ -322,6 +354,14 @@ class CSSStyleSheet(CSSNode):
|
|
|
322
354
|
self.rules.append(CSSKeyframesRule(name=name, frames=frames))
|
|
323
355
|
return self
|
|
324
356
|
|
|
357
|
+
def add_custom_properties(
|
|
358
|
+
self,
|
|
359
|
+
props: Mapping[str, Any],
|
|
360
|
+
selector: str = ":root",
|
|
361
|
+
) -> "CSSStyleSheet":
|
|
362
|
+
self.rules.append(CSSCustomProperties(props, selector=selector))
|
|
363
|
+
return self
|
|
364
|
+
|
|
325
365
|
def add_raw(self, css: str) -> "CSSStyleSheet":
|
|
326
366
|
self.rules.append(css)
|
|
327
367
|
return self
|
|
@@ -358,6 +398,7 @@ def inline_style(
|
|
|
358
398
|
__all__ = [
|
|
359
399
|
"CSSAtRule",
|
|
360
400
|
"CSSComment",
|
|
401
|
+
"CSSCustomProperties",
|
|
361
402
|
"CSSDeclaration",
|
|
362
403
|
"CSSLink",
|
|
363
404
|
"CSSImportRule",
|
|
@@ -376,6 +417,7 @@ __all__ = [
|
|
|
376
417
|
"GOOGLE_FONTS_STATIC_URL",
|
|
377
418
|
"TAILWIND_PLAY_CDN_URL",
|
|
378
419
|
"bootstrap5_stylesheet",
|
|
420
|
+
"css_var",
|
|
379
421
|
"google_fonts_assets",
|
|
380
422
|
"google_fonts_url",
|
|
381
423
|
"inline_style",
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""File loaders that read HTML and CSS into html5 document objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from html.parser import HTMLParser
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .css import CSSStyleSheet
|
|
9
|
+
from .document import HtmlDocument, Raw
|
|
10
|
+
|
|
11
|
+
_VOID_ELEMENTS = frozenset(
|
|
12
|
+
{
|
|
13
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
14
|
+
"link", "meta", "param", "source", "track", "wbr",
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _HTMLStructureParser(HTMLParser):
|
|
20
|
+
"""Extract lang, title, head content, and body content from an HTML file."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
super().__init__(convert_charrefs=False)
|
|
24
|
+
self.lang: str = "en"
|
|
25
|
+
self.title: str = ""
|
|
26
|
+
self._section: str = ""
|
|
27
|
+
self._in_title: bool = False
|
|
28
|
+
self._head_buf: list[str] = []
|
|
29
|
+
self._body_buf: list[str] = []
|
|
30
|
+
|
|
31
|
+
def _buf(self) -> list[str]:
|
|
32
|
+
if self._section == "head":
|
|
33
|
+
return self._head_buf
|
|
34
|
+
if self._section == "body":
|
|
35
|
+
return self._body_buf
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
39
|
+
attr_map = dict(attrs)
|
|
40
|
+
if tag == "html":
|
|
41
|
+
self.lang = attr_map.get("lang", "en") or "en"
|
|
42
|
+
return
|
|
43
|
+
if tag == "head":
|
|
44
|
+
self._section = "head"
|
|
45
|
+
return
|
|
46
|
+
if tag == "body":
|
|
47
|
+
self._section = "body"
|
|
48
|
+
return
|
|
49
|
+
if tag == "title" and self._section == "head":
|
|
50
|
+
self._in_title = True
|
|
51
|
+
return
|
|
52
|
+
if tag == "meta" and self._section == "head" and "charset" in attr_map:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
if self._in_title:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
attrs_str = "".join(
|
|
59
|
+
f" {k}" if v is None else f' {k}="{v}"'
|
|
60
|
+
for k, v in attrs
|
|
61
|
+
)
|
|
62
|
+
self._buf().append(f"<{tag}{attrs_str}>")
|
|
63
|
+
|
|
64
|
+
def handle_endtag(self, tag: str) -> None:
|
|
65
|
+
if tag in ("html", "head", "body"):
|
|
66
|
+
if tag == "head":
|
|
67
|
+
self._section = ""
|
|
68
|
+
elif tag == "body":
|
|
69
|
+
self._section = ""
|
|
70
|
+
return
|
|
71
|
+
if tag == "title" and self._in_title:
|
|
72
|
+
self._in_title = False
|
|
73
|
+
return
|
|
74
|
+
if tag in _VOID_ELEMENTS or self._in_title:
|
|
75
|
+
return
|
|
76
|
+
self._buf().append(f"</{tag}>")
|
|
77
|
+
|
|
78
|
+
def handle_data(self, data: str) -> None:
|
|
79
|
+
if self._in_title:
|
|
80
|
+
self.title += data
|
|
81
|
+
else:
|
|
82
|
+
self._buf().append(data)
|
|
83
|
+
|
|
84
|
+
def handle_comment(self, data: str) -> None:
|
|
85
|
+
self._buf().append(f"<!--{data}-->")
|
|
86
|
+
|
|
87
|
+
def handle_entityref(self, name: str) -> None:
|
|
88
|
+
self._buf().append(f"&{name};")
|
|
89
|
+
|
|
90
|
+
def handle_charref(self, name: str) -> None:
|
|
91
|
+
self._buf().append(f"&#{name};")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def html_loader(path: str | Path) -> HtmlDocument:
|
|
95
|
+
"""Read an HTML file and return an :class:`HtmlDocument`.
|
|
96
|
+
|
|
97
|
+
The document title, ``lang`` attribute, head nodes, and body content are
|
|
98
|
+
extracted from the file. The charset ``<meta>`` tag is stripped (since
|
|
99
|
+
:meth:`HtmlDocument.render` always emits one). All other head and body
|
|
100
|
+
content is preserved as a single :class:`Raw` node each.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
path: Path to the HTML file to read.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
An :class:`HtmlDocument` populated from the file.
|
|
107
|
+
"""
|
|
108
|
+
content = Path(path).read_text(encoding="utf-8")
|
|
109
|
+
parser = _HTMLStructureParser()
|
|
110
|
+
parser.feed(content)
|
|
111
|
+
|
|
112
|
+
doc = HtmlDocument(title=parser.title, lang=parser.lang)
|
|
113
|
+
|
|
114
|
+
head_html = "".join(parser._head_buf).strip()
|
|
115
|
+
if head_html:
|
|
116
|
+
doc.add_head(Raw(head_html))
|
|
117
|
+
|
|
118
|
+
body_html = "".join(parser._body_buf).strip()
|
|
119
|
+
if body_html:
|
|
120
|
+
doc.add_body(Raw(body_html))
|
|
121
|
+
|
|
122
|
+
return doc
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def css_loader(path: str | Path) -> CSSStyleSheet:
|
|
126
|
+
"""Read a CSS file and return a :class:`CSSStyleSheet`.
|
|
127
|
+
|
|
128
|
+
The file content is stored as a single raw CSS block; no parsing of
|
|
129
|
+
individual rules is performed.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
path: Path to the CSS file to read.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
A :class:`CSSStyleSheet` whose rendered output is the file content.
|
|
136
|
+
"""
|
|
137
|
+
content = Path(path).read_text(encoding="utf-8")
|
|
138
|
+
return CSSStyleSheet().add_raw(content)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
__all__ = ["html_loader", "css_loader"]
|
|
@@ -6,6 +6,7 @@ import pytest
|
|
|
6
6
|
|
|
7
7
|
from html5 import (
|
|
8
8
|
CSSAtRule,
|
|
9
|
+
CSSCustomProperties,
|
|
9
10
|
CSSDeclaration,
|
|
10
11
|
CSSKeyframe,
|
|
11
12
|
CSSLayerRule,
|
|
@@ -22,10 +23,13 @@ from html5 import (
|
|
|
22
23
|
__version__,
|
|
23
24
|
bootstrap5_bundle_script,
|
|
24
25
|
bootstrap5_stylesheet,
|
|
26
|
+
css_loader,
|
|
27
|
+
css_var,
|
|
25
28
|
google_fonts_assets,
|
|
26
29
|
google_fonts_url,
|
|
27
30
|
google_charts_loader,
|
|
28
31
|
google_charts_package_loader,
|
|
32
|
+
html_loader,
|
|
29
33
|
javascript_link,
|
|
30
34
|
javascript_script,
|
|
31
35
|
inline_style,
|
|
@@ -35,7 +39,7 @@ from html5 import (
|
|
|
35
39
|
|
|
36
40
|
|
|
37
41
|
def test_version_uses_semver() -> None:
|
|
38
|
-
assert __version__ == "0.
|
|
42
|
+
assert __version__ == "0.4.0"
|
|
39
43
|
assert __version__.count(".") == 2
|
|
40
44
|
|
|
41
45
|
|
|
@@ -246,3 +250,139 @@ def test_markup_writer_rejects_dotdot_traversal(tmp_path: Path) -> None:
|
|
|
246
250
|
def test_void_html_elements_reject_children() -> None:
|
|
247
251
|
with pytest.raises(ValueError):
|
|
248
252
|
Img("unexpected child", src="hero.png")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# CSS custom properties
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def test_css_custom_properties_renders_root_block() -> None:
|
|
260
|
+
props = CSSCustomProperties({"--size-1": "0.25rem", "--size-2": "0.5rem"})
|
|
261
|
+
rendered = props.render()
|
|
262
|
+
assert rendered.startswith(":root {")
|
|
263
|
+
assert "--size-1: 0.25rem;" in rendered
|
|
264
|
+
assert "--size-2: 0.5rem;" in rendered
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_css_custom_properties_custom_selector() -> None:
|
|
268
|
+
props = CSSCustomProperties({"--accent": "#005fcc"}, selector=".theme-dark")
|
|
269
|
+
rendered = props.render()
|
|
270
|
+
assert rendered.startswith(".theme-dark {")
|
|
271
|
+
assert "--accent: #005fcc;" in rendered
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_css_custom_properties_empty_renders_empty_block() -> None:
|
|
275
|
+
rendered = CSSCustomProperties({}).render()
|
|
276
|
+
assert rendered == ":root {}"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_css_var_returns_var_reference() -> None:
|
|
280
|
+
assert css_var("--size-1") == "var(--size-1)"
|
|
281
|
+
assert css_var("--color-brand") == "var(--color-brand)"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_stylesheet_add_custom_properties() -> None:
|
|
285
|
+
sheet = (
|
|
286
|
+
CSSStyleSheet()
|
|
287
|
+
.add_custom_properties({"--gap": "1rem", "--radius": "4px"})
|
|
288
|
+
.add_rule("button", ("border_radius", css_var("--radius")))
|
|
289
|
+
)
|
|
290
|
+
rendered = sheet.render()
|
|
291
|
+
assert ":root { --gap: 1rem; --radius: 4px; }" in rendered
|
|
292
|
+
assert "border-radius: var(--radius);" in rendered
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_stylesheet_add_custom_properties_custom_selector() -> None:
|
|
296
|
+
sheet = CSSStyleSheet().add_custom_properties(
|
|
297
|
+
{"--fg": "#fff"}, selector="[data-theme='dark']"
|
|
298
|
+
)
|
|
299
|
+
rendered = sheet.render()
|
|
300
|
+
assert "[data-theme='dark'] { --fg: #fff; }" in rendered
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_css_declaration_custom_property_passthrough() -> None:
|
|
304
|
+
decl = CSSDeclaration("--my-color", "red")
|
|
305
|
+
assert decl.render() == "--my-color: red;"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# html_loader / css_loader
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def test_css_loader_reads_file(tmp_path: Path) -> None:
|
|
313
|
+
css_file = tmp_path / "styles.css"
|
|
314
|
+
css_file.write_text("body { margin: 0; }\nh1 { font-size: 2rem; }", encoding="utf-8")
|
|
315
|
+
sheet = css_loader(css_file)
|
|
316
|
+
rendered = sheet.render()
|
|
317
|
+
assert "body { margin: 0; }" in rendered
|
|
318
|
+
assert "h1 { font-size: 2rem; }" in rendered
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_css_loader_returns_stylesheet_instance(tmp_path: Path) -> None:
|
|
322
|
+
css_file = tmp_path / "x.css"
|
|
323
|
+
css_file.write_text("p { color: blue; }", encoding="utf-8")
|
|
324
|
+
assert isinstance(css_loader(css_file), CSSStyleSheet)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_html_loader_reads_title_and_lang(tmp_path: Path) -> None:
|
|
328
|
+
html_file = tmp_path / "index.html"
|
|
329
|
+
html_file.write_text(
|
|
330
|
+
'<!doctype html>\n<html lang="fr"><head><meta charset="utf-8">'
|
|
331
|
+
"<title>Bonjour</title></head><body><p>Texte</p></body></html>",
|
|
332
|
+
encoding="utf-8",
|
|
333
|
+
)
|
|
334
|
+
doc = html_loader(html_file)
|
|
335
|
+
assert doc.title == "Bonjour"
|
|
336
|
+
assert doc.lang == "fr"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_html_loader_body_content_preserved(tmp_path: Path) -> None:
|
|
340
|
+
html_file = tmp_path / "page.html"
|
|
341
|
+
html_file.write_text(
|
|
342
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
|
343
|
+
"<title>Test</title></head><body><div><h1>Hello</h1></div></body></html>",
|
|
344
|
+
encoding="utf-8",
|
|
345
|
+
)
|
|
346
|
+
doc = html_loader(html_file)
|
|
347
|
+
rendered = doc.render()
|
|
348
|
+
assert "<h1>Hello</h1>" in rendered
|
|
349
|
+
assert "<div>" in rendered
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_html_loader_strips_charset_meta(tmp_path: Path) -> None:
|
|
353
|
+
html_file = tmp_path / "page.html"
|
|
354
|
+
html_file.write_text(
|
|
355
|
+
'<!doctype html><html><head><meta charset="utf-8"><title>X</title>'
|
|
356
|
+
"</head><body></body></html>",
|
|
357
|
+
encoding="utf-8",
|
|
358
|
+
)
|
|
359
|
+
doc = html_loader(html_file)
|
|
360
|
+
rendered = doc.render()
|
|
361
|
+
assert rendered.count('charset="utf-8"') == 1
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def test_html_loader_returns_html_document(tmp_path: Path) -> None:
|
|
365
|
+
html_file = tmp_path / "page.html"
|
|
366
|
+
html_file.write_text(
|
|
367
|
+
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Hi</title>"
|
|
368
|
+
"</head><body></body></html>",
|
|
369
|
+
encoding="utf-8",
|
|
370
|
+
)
|
|
371
|
+
assert isinstance(html_loader(html_file), HtmlDocument)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_html_loader_roundtrip(tmp_path: Path) -> None:
|
|
375
|
+
sheet = CSSStyleSheet().add_custom_properties({"--size-1": "0.25rem"})
|
|
376
|
+
original = (
|
|
377
|
+
HtmlDocument(title="Round Trip", lang="en")
|
|
378
|
+
.add_head(style_tag(sheet))
|
|
379
|
+
.add_body(Div(H1("Hello")))
|
|
380
|
+
)
|
|
381
|
+
writer = MarkupWriter(root=tmp_path)
|
|
382
|
+
html_path = writer.write_html("page.html", original)
|
|
383
|
+
doc = html_loader(html_path)
|
|
384
|
+
assert doc.title == "Round Trip"
|
|
385
|
+
assert doc.lang == "en"
|
|
386
|
+
rendered = doc.render()
|
|
387
|
+
assert "--size-1: 0.25rem;" in rendered
|
|
388
|
+
assert "Hello" in rendered
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|