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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: html5cc
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A small Python module for building HTML5 documents and CSS3 stylesheets.
5
5
  Author: Jason Miller
6
6
  License: MIT
@@ -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"]
@@ -1,3 +1,3 @@
1
1
  """Package version information."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: html5cc
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A small Python module for building HTML5 documents and CSS3 stylesheets.
5
5
  Author: Jason Miller
6
6
  License: MIT
@@ -6,6 +6,7 @@ src/html5/css.py
6
6
  src/html5/document.py
7
7
  src/html5/elements.py
8
8
  src/html5/js.py
9
+ src/html5/loader.py
9
10
  src/html5/version.py
10
11
  src/html5/writer.py
11
12
  src/html5cc.egg-info/PKG-INFO
@@ -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.3.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