From 0d2ec173f5f2028a5e2f2b0c43fc152b75028f67 Mon Sep 17 00:00:00 2001
From: Sean N
Date: Mon, 10 Nov 2025 20:34:04 +0200
Subject: [PATCH 1/7] Cleanups
---
.gitignore | 4 +++-
pyproject.toml | 18 ++++++++-------
run_tests.py | 6 ++---
src/ydnatl/core/element.py | 46 +++++++++++++++++++++-----------------
src/ydnatl/tags/form.py | 1 -
src/ydnatl/tags/layout.py | 1 -
src/ydnatl/tags/lists.py | 4 ++--
src/ydnatl/tags/media.py | 1 -
src/ydnatl/tags/table.py | 1 -
src/ydnatl/tags/text.py | 1 -
tests/test_form.py | 2 +-
tests/test_html.py | 4 ++--
tests/test_layout.py | 2 +-
tests/test_lists.py | 2 +-
tests/test_media.py | 2 +-
tests/test_table.py | 8 +++----
tests/test_text.py | 2 +-
17 files changed, 54 insertions(+), 51 deletions(-)
diff --git a/.gitignore b/.gitignore
index b5dd7c0..28712df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,4 +28,6 @@ venv/
ENV/
env/
-.DS_Store
\ No newline at end of file
+.DS_Store
+.idea/
+.vscode/
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index bf7fb64..a613f86 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,7 +10,7 @@ readme = "README.md"
requires-python = ">=3.8"
license-files = ["LICEN[CS]E*"]
authors = [
- { name = "Sean Nieuwoudt", email = "sean@underwulf.com" }
+ { name = "Sean Nieuwoudt", email = "sean@underwulf.com" }
]
classifiers = [
"Programming Language :: Python :: 3",
@@ -26,17 +26,19 @@ Repository = "https://github.com/sn/ydnatl"
Issues = "https://github.com/sn/ydnatl/issues"
[tool.setuptools]
-package-dir = {"" = "src"}
+package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
[project.optional-dependencies]
dev = [
- "build",
- "twine",
- "pytest",
- "iniconfig",
- "pluggy",
- "packaging"
+ "build",
+ "twine",
+ "pytest",
+ "iniconfig",
+ "pluggy",
+ "packaging",
+ "black",
+ "flake8"
]
\ No newline at end of file
diff --git a/run_tests.py b/run_tests.py
index 8bbedc3..762da2b 100644
--- a/run_tests.py
+++ b/run_tests.py
@@ -1,7 +1,7 @@
import unittest
-if __name__ == '__main__':
+if __name__ == "__main__":
test_loader = unittest.TestLoader()
- test_suite = test_loader.discover('tests')
+ test_suite = test_loader.discover("tests")
test_runner = unittest.TextTestRunner()
- test_runner.run(test_suite)
\ No newline at end of file
+ test_runner.run(test_suite)
diff --git a/src/ydnatl/core/element.py b/src/ydnatl/core/element.py
index f93538f..8e7c75a 100644
--- a/src/ydnatl/core/element.py
+++ b/src/ydnatl/core/element.py
@@ -1,9 +1,7 @@
-import uuid
import copy
-import os
-import functools
import html
-
+import os
+import uuid
from typing import Callable, Any, Iterator, Union, List
@@ -11,18 +9,21 @@ class HTMLElement:
__slots__ = ["_tag", "_children", "_text", "_attributes", "_self_closing"]
def __init__(
- self,
- *children: Union["HTMLElement", str, List[Any]],
- tag: str,
- self_closing: bool = False,
- **attributes: str,
+ self,
+ *children: Union["HTMLElement", str, List[Any]],
+ tag: str,
+ self_closing: bool = False,
+ **attributes: str,
):
PRESERVE_UNDERSCORE = {"class_name"}
-
+
if not tag:
raise ValueError("A valid HTML tag name is required")
-
- fixed_attributes = {(k if k in PRESERVE_UNDERSCORE else k.replace("_", "-")): v for k, v in attributes.items()}
+
+ fixed_attributes = {
+ (k if k in PRESERVE_UNDERSCORE else k.replace("_", "-")): v
+ for k, v in attributes.items()
+ }
self._tag: str = tag
self._children: List[HTMLElement] = []
@@ -40,7 +41,7 @@ def __init__(
def __str__(self) -> str:
return self.render()
-
+
def __del__(self) -> None:
self.on_unload()
@@ -78,7 +79,7 @@ def append(self, *children: Union["HTMLElement", str, List[Any]]) -> None:
self._add_child(child)
def filter(
- self, condition: Callable[[Any], bool], recursive: bool = False
+ self, condition: Callable[[Any], bool], recursive: bool = False
) -> Iterator["HTMLElement"]:
"""Yields children (and optionally descendants) that meet the condition."""
for child in self._children:
@@ -113,12 +114,12 @@ def last(self) -> Union["HTMLElement", None]:
def add_attribute(self, key: str, value: str) -> None:
"""Adds an attribute to the current tag."""
self._attributes[key] = value
-
+
def add_attributes(self, attributes: list[tuple[str, str]]) -> None:
"""Adds multiple attributes to the current tag."""
for key, value in attributes:
self._attributes[key] = value
-
+
def remove_attribute(self, key: str) -> None:
"""Removes an attribute from the current tag."""
self._attributes.pop(key, None)
@@ -139,13 +140,16 @@ def generate_id(self) -> None:
def clone(self) -> "HTMLElement":
"""Clones the current tag."""
return copy.deepcopy(self)
-
+
def replace_child(self, old_index: int, new_child: "HTMLElement") -> None:
"""Replaces a existing child element with a new child element."""
self._children[old_index] = new_child
- def find_by_attribute(self, attr_name: str, attr_value: Any) -> Union["HTMLElement", None]:
+ def find_by_attribute(
+ self, attr_name: str, attr_value: Any
+ ) -> Union["HTMLElement", None]:
"""Finds a child by an attribute."""
+
def _find(element: "HTMLElement") -> Union["HTMLElement", None]:
if element.get_attribute(attr_name) == attr_value:
return element
@@ -249,12 +253,12 @@ def render(self) -> str:
self.on_after_render()
return result
-
+
def to_dict(self) -> dict:
return {
"tag": self._tag,
"self_closing": self._self_closing,
"attributes": self._attributes.copy(),
"text": self._text,
- "children": list(map(lambda child: child.to_dict(), self._children))
- }
\ No newline at end of file
+ "children": list(map(lambda child: child.to_dict(), self._children)),
+ }
diff --git a/src/ydnatl/tags/form.py b/src/ydnatl/tags/form.py
index 9e4df29..8ca163b 100644
--- a/src/ydnatl/tags/form.py
+++ b/src/ydnatl/tags/form.py
@@ -1,7 +1,6 @@
from ydnatl.core.element import HTMLElement
from ydnatl.tags.tag_factory import simple_tag_class
-
Textarea = simple_tag_class("textarea")
BaseSelect = simple_tag_class("select")
Option = simple_tag_class("option")
diff --git a/src/ydnatl/tags/layout.py b/src/ydnatl/tags/layout.py
index 2c9105f..e4ccd82 100644
--- a/src/ydnatl/tags/layout.py
+++ b/src/ydnatl/tags/layout.py
@@ -1,6 +1,5 @@
from ydnatl.tags.tag_factory import simple_tag_class
-
Div = simple_tag_class("div")
Section = simple_tag_class("section")
Header = simple_tag_class("header")
diff --git a/src/ydnatl/tags/lists.py b/src/ydnatl/tags/lists.py
index 9a9ca1d..85a5055 100644
--- a/src/ydnatl/tags/lists.py
+++ b/src/ydnatl/tags/lists.py
@@ -2,8 +2,8 @@
from ydnatl.tags.tag_factory import simple_tag_class
-UnorderedList = simple_tag_class("ul")
-OrderedList = simple_tag_class("ol")
+# UnorderedList = simple_tag_class("ul")
+# OrderedList = simple_tag_class("ol")
ListItem = simple_tag_class("li")
Datalist = simple_tag_class("datalist")
DescriptionDetails = simple_tag_class("dd")
diff --git a/src/ydnatl/tags/media.py b/src/ydnatl/tags/media.py
index 34647f6..6407057 100644
--- a/src/ydnatl/tags/media.py
+++ b/src/ydnatl/tags/media.py
@@ -1,6 +1,5 @@
from ydnatl.tags.tag_factory import simple_tag_class
-
Image = simple_tag_class("img", self_closing=True)
Video = simple_tag_class("video")
Audio = simple_tag_class("audio")
diff --git a/src/ydnatl/tags/table.py b/src/ydnatl/tags/table.py
index 053c7b9..e50ee7e 100644
--- a/src/ydnatl/tags/table.py
+++ b/src/ydnatl/tags/table.py
@@ -4,7 +4,6 @@
from ydnatl.core.element import HTMLElement
from ydnatl.tags.tag_factory import simple_tag_class
-
TableFooter = simple_tag_class("tfoot")
TableHeaderCell = simple_tag_class("th")
TableHeader = simple_tag_class("thead")
diff --git a/src/ydnatl/tags/text.py b/src/ydnatl/tags/text.py
index 15ed806..d99504f 100644
--- a/src/ydnatl/tags/text.py
+++ b/src/ydnatl/tags/text.py
@@ -1,6 +1,5 @@
from ydnatl.tags.tag_factory import simple_tag_class
-
H1 = simple_tag_class("h1")
H2 = simple_tag_class("h2")
H3 = simple_tag_class("h3")
diff --git a/tests/test_form.py b/tests/test_form.py
index 747874b..acaa8fc 100644
--- a/tests/test_form.py
+++ b/tests/test_form.py
@@ -1,5 +1,6 @@
import unittest
+from ydnatl.core.element import HTMLElement
from ydnatl.tags.form import (
Textarea,
Select,
@@ -11,7 +12,6 @@
Label,
Optgroup,
)
-from ydnatl.core.element import HTMLElement
class TestFormTags(unittest.TestCase):
diff --git a/tests/test_html.py b/tests/test_html.py
index 695e39b..590205f 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -1,5 +1,6 @@
import unittest
+from ydnatl.core.element import HTMLElement
from ydnatl.tags.html import (
HTML,
Head,
@@ -11,7 +12,6 @@
Style,
IFrame,
)
-from ydnatl.core.element import HTMLElement
class TestHTMLTags(unittest.TestCase):
@@ -68,7 +68,7 @@ def test_html(self):
"""Test the creation of an empty HTML document."""
html = HTML()
self.assertEqual(html.tag, "html")
- expected = ""
+ expected = ''
self.assertEqual(str(html), expected)
def test_inheritance(self):
diff --git a/tests/test_layout.py b/tests/test_layout.py
index 4c925a9..2a8eeb1 100644
--- a/tests/test_layout.py
+++ b/tests/test_layout.py
@@ -1,5 +1,6 @@
import unittest
+from ydnatl.core.element import HTMLElement
from ydnatl.tags.layout import (
Div,
Section,
@@ -9,7 +10,6 @@
HorizontalRule,
Main,
)
-from ydnatl.core.element import HTMLElement
class TestLayoutTags(unittest.TestCase):
diff --git a/tests/test_lists.py b/tests/test_lists.py
index b071bce..8b0c95d 100644
--- a/tests/test_lists.py
+++ b/tests/test_lists.py
@@ -1,5 +1,6 @@
import unittest
+from ydnatl.core.element import HTMLElement
from ydnatl.tags.lists import (
UnorderedList,
OrderedList,
@@ -9,7 +10,6 @@
DescriptionList,
DescriptionTerm,
)
-from ydnatl.core.element import HTMLElement
class TestListTags(unittest.TestCase):
diff --git a/tests/test_media.py b/tests/test_media.py
index 0b3d7d0..1e1f46c 100644
--- a/tests/test_media.py
+++ b/tests/test_media.py
@@ -1,5 +1,6 @@
import unittest
+from ydnatl.core.element import HTMLElement
from ydnatl.tags.media import (
Image,
Video,
@@ -10,7 +11,6 @@
Figcaption,
Canvas,
)
-from ydnatl.core.element import HTMLElement
class TestMediaTags(unittest.TestCase):
diff --git a/tests/test_table.py b/tests/test_table.py
index 6ecf3b2..902b168 100644
--- a/tests/test_table.py
+++ b/tests/test_table.py
@@ -1,9 +1,10 @@
-import unittest
-import os
-import tempfile
import csv
import json
+import os
+import tempfile
+import unittest
+from ydnatl.core.element import HTMLElement
from ydnatl.tags.table import (
Table,
TableFooter,
@@ -13,7 +14,6 @@
TableDataCell,
TableRow,
)
-from ydnatl.core.element import HTMLElement
class TestTableTags(unittest.TestCase):
diff --git a/tests/test_text.py b/tests/test_text.py
index 846e90d..9c13d97 100644
--- a/tests/test_text.py
+++ b/tests/test_text.py
@@ -28,7 +28,7 @@
class TestTextTags(unittest.TestCase):
-
+
def test_h1_with_attributes(self):
"""Test the creation of an H1 element with attributes."""
h1 = H1("Heading 1", id="heading-1", class_name="heading-1")
From be6c75fc6d09778d8f8296e86bdfbb954b05fb88 Mon Sep 17 00:00:00 2001
From: Sean N
Date: Mon, 10 Nov 2025 20:56:44 +0200
Subject: [PATCH 2/7] Added additional tags that make this sensible for
production use
---
src/ydnatl/tags/form.py | 8 +++
src/ydnatl/tags/html.py | 2 +
src/ydnatl/tags/layout.py | 5 ++
src/ydnatl/tags/lists.py | 3 --
src/ydnatl/tags/media.py | 6 +++
src/ydnatl/tags/table.py | 3 ++
src/ydnatl/tags/tag_factory.py | 10 +++-
src/ydnatl/tags/text.py | 12 +++++
tests/test_form.py | 44 +++++++++++++++
tests/test_html.py | 39 +++++++++++++-
tests/test_layout.py | 64 +++++++++++++++++++++-
tests/test_media.py | 99 +++++++++++++++++++++++++++++++++-
tests/test_table.py | 45 ++++++++++++++++
tests/test_text.py | 98 +++++++++++++++++++++++++++++++++
14 files changed, 431 insertions(+), 7 deletions(-)
diff --git a/src/ydnatl/tags/form.py b/src/ydnatl/tags/form.py
index 8ca163b..90fe941 100644
--- a/src/ydnatl/tags/form.py
+++ b/src/ydnatl/tags/form.py
@@ -6,8 +6,12 @@
Option = simple_tag_class("option")
Button = simple_tag_class("button")
Fieldset = simple_tag_class("fieldset")
+Legend = simple_tag_class("legend")
Input = simple_tag_class("input", self_closing=True)
Optgroup = simple_tag_class("optgroup")
+Output = simple_tag_class("output")
+Progress = simple_tag_class("progress")
+Meter = simple_tag_class("meter")
class Select(BaseSelect):
@@ -44,8 +48,12 @@ def with_fields(*items, **kwargs):
Option,
Button,
Fieldset,
+ Legend,
Label,
Optgroup,
+ Output,
+ Progress,
+ Meter,
)
for item in items:
if not isinstance(item, valid_types):
diff --git a/src/ydnatl/tags/html.py b/src/ydnatl/tags/html.py
index 22fae29..867657a 100644
--- a/src/ydnatl/tags/html.py
+++ b/src/ydnatl/tags/html.py
@@ -19,7 +19,9 @@ def __init__(self, *args, **kwargs):
Body = simple_tag_class("body")
Title = simple_tag_class("title")
Meta = simple_tag_class("meta", self_closing=True)
+Base = simple_tag_class("base", self_closing=True)
Link = simple_tag_class("link", self_closing=True)
Script = simple_tag_class("script")
Style = simple_tag_class("style")
+Noscript = simple_tag_class("noscript")
IFrame = simple_tag_class("iframe")
diff --git a/src/ydnatl/tags/layout.py b/src/ydnatl/tags/layout.py
index e4ccd82..69c001d 100644
--- a/src/ydnatl/tags/layout.py
+++ b/src/ydnatl/tags/layout.py
@@ -2,8 +2,13 @@
Div = simple_tag_class("div")
Section = simple_tag_class("section")
+Article = simple_tag_class("article")
+Aside = simple_tag_class("aside")
Header = simple_tag_class("header")
Nav = simple_tag_class("nav")
Footer = simple_tag_class("footer")
HorizontalRule = simple_tag_class("hr", self_closing=True)
Main = simple_tag_class("main")
+Details = simple_tag_class("details")
+Summary = simple_tag_class("summary")
+Dialog = simple_tag_class("dialog")
diff --git a/src/ydnatl/tags/lists.py b/src/ydnatl/tags/lists.py
index 85a5055..1d3c204 100644
--- a/src/ydnatl/tags/lists.py
+++ b/src/ydnatl/tags/lists.py
@@ -1,9 +1,6 @@
from ydnatl.core.element import HTMLElement
from ydnatl.tags.tag_factory import simple_tag_class
-
-# UnorderedList = simple_tag_class("ul")
-# OrderedList = simple_tag_class("ol")
ListItem = simple_tag_class("li")
Datalist = simple_tag_class("datalist")
DescriptionDetails = simple_tag_class("dd")
diff --git a/src/ydnatl/tags/media.py b/src/ydnatl/tags/media.py
index 6407057..f2c347e 100644
--- a/src/ydnatl/tags/media.py
+++ b/src/ydnatl/tags/media.py
@@ -4,7 +4,13 @@
Video = simple_tag_class("video")
Audio = simple_tag_class("audio")
Source = simple_tag_class("source", self_closing=True)
+Track = simple_tag_class("track", self_closing=True)
Picture = simple_tag_class("picture")
Figure = simple_tag_class("figure")
Figcaption = simple_tag_class("figcaption")
Canvas = simple_tag_class("canvas")
+Embed = simple_tag_class("embed", self_closing=True)
+Object = simple_tag_class("object")
+Param = simple_tag_class("param", self_closing=True)
+Map = simple_tag_class("map")
+Area = simple_tag_class("area", self_closing=True)
diff --git a/src/ydnatl/tags/table.py b/src/ydnatl/tags/table.py
index e50ee7e..2eb922a 100644
--- a/src/ydnatl/tags/table.py
+++ b/src/ydnatl/tags/table.py
@@ -10,6 +10,9 @@
TableBody = simple_tag_class("tbody")
TableDataCell = simple_tag_class("td")
TableRow = simple_tag_class("tr")
+Caption = simple_tag_class("caption")
+Col = simple_tag_class("col", self_closing=True)
+Colgroup = simple_tag_class("colgroup")
class Table(HTMLElement):
diff --git a/src/ydnatl/tags/tag_factory.py b/src/ydnatl/tags/tag_factory.py
index 2333cdb..efe540b 100644
--- a/src/ydnatl/tags/tag_factory.py
+++ b/src/ydnatl/tags/tag_factory.py
@@ -3,6 +3,12 @@
# Factory function to create simple tag classes
def simple_tag_class(tag, self_closing=False, extra_init=None):
+ # Add validation
+ if not isinstance(tag, str):
+ raise TypeError(f"tag must be a string, got {type(tag)}")
+ if extra_init is not None and not callable(extra_init):
+ raise TypeError("extra_init must be callable")
+
class _Tag(HTMLElement):
def __init__(self, *args, **kwargs):
if extra_init:
@@ -16,5 +22,7 @@ def __init__(self, *args, **kwargs):
},
)
- _Tag.__name__ = tag.capitalize() if tag.islower() else tag
+ class_name = tag.capitalize() if tag.islower() else tag
+ _Tag.__name__ = class_name
+ _Tag.__qualname__ = class_name
return _Tag
diff --git a/src/ydnatl/tags/text.py b/src/ydnatl/tags/text.py
index d99504f..8b41ec0 100644
--- a/src/ydnatl/tags/text.py
+++ b/src/ydnatl/tags/text.py
@@ -15,6 +15,7 @@
Italic = simple_tag_class("i")
Span = simple_tag_class("span")
Strong = simple_tag_class("strong")
+Bold = simple_tag_class("b")
Abbr = simple_tag_class("abbr")
Link = simple_tag_class("a")
Small = simple_tag_class("small")
@@ -22,3 +23,14 @@
Subscript = simple_tag_class("sub")
Time = simple_tag_class("time")
Code = simple_tag_class("code")
+Del = simple_tag_class("del")
+Ins = simple_tag_class("ins")
+Strikethrough = simple_tag_class("s")
+Underline = simple_tag_class("u")
+Kbd = simple_tag_class("kbd")
+Samp = simple_tag_class("samp")
+Var = simple_tag_class("var")
+Mark = simple_tag_class("mark")
+Dfn = simple_tag_class("dfn")
+Br = simple_tag_class("br", self_closing=True)
+Wbr = simple_tag_class("wbr", self_closing=True)
diff --git a/tests/test_form.py b/tests/test_form.py
index acaa8fc..f5b92dc 100644
--- a/tests/test_form.py
+++ b/tests/test_form.py
@@ -7,10 +7,14 @@
Option,
Button,
Fieldset,
+ Legend,
Form,
Input,
Label,
Optgroup,
+ Output,
+ Progress,
+ Meter,
)
@@ -96,6 +100,42 @@ def test_optgroup(self):
'',
)
+ def test_legend(self):
+ """Test the creation of a legend element."""
+ legend = Legend("Personal Information")
+ self.assertEqual(legend.tag, "legend")
+ self.assertEqual(str(legend), "")
+
+ def test_fieldset_with_legend(self):
+ """Test fieldset element with legend and fields."""
+ fieldset = Fieldset(
+ Legend("Contact Details"),
+ Input(type="email", name="email"),
+ )
+ expected = ''
+ self.assertEqual(str(fieldset), expected)
+
+ def test_output(self):
+ """Test the creation of an output element."""
+ output = Output("42", for_attr="calculation")
+ self.assertEqual(output.tag, "output")
+ self.assertIn("42", str(output))
+
+ def test_progress(self):
+ """Test the creation of a progress element."""
+ progress = Progress(value="70", max="100")
+ self.assertEqual(progress.tag, "progress")
+ self.assertIn('value="70"', str(progress))
+ self.assertIn('max="100"', str(progress))
+
+ def test_meter(self):
+ """Test the creation of a meter element."""
+ meter = Meter(value="6", min="0", max="10")
+ self.assertEqual(meter.tag, "meter")
+ self.assertIn('value="6"', str(meter))
+ self.assertIn('min="0"', str(meter))
+ self.assertIn('max="10"', str(meter))
+
def test_inheritance(self):
"""Test that all form-related classes inherit from HTMLElement."""
for cls in [
@@ -104,10 +144,14 @@ def test_inheritance(self):
Option,
Button,
Fieldset,
+ Legend,
Form,
Input,
Label,
Optgroup,
+ Output,
+ Progress,
+ Meter,
]:
self.assertTrue(issubclass(cls, HTMLElement))
diff --git a/tests/test_html.py b/tests/test_html.py
index 590205f..c7046dc 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -7,9 +7,11 @@
Body,
Title,
Meta,
+ Base,
Link,
Script,
Style,
+ Noscript,
IFrame,
)
@@ -71,9 +73,44 @@ def test_html(self):
expected = ''
self.assertEqual(str(html), expected)
+ def test_base(self):
+ """Test the creation of a base element."""
+ base = Base(href="https://www.example.com/", target="_blank")
+ self.assertEqual(base.tag, "base")
+ self.assertTrue(base.self_closing)
+ self.assertIn('href="https://www.example.com/"', str(base))
+ self.assertIn('target="_blank"', str(base))
+
+ def test_noscript(self):
+ """Test the creation of a noscript element."""
+ noscript = Noscript("JavaScript is required to view this page.")
+ self.assertEqual(noscript.tag, "noscript")
+ self.assertEqual(
+ str(noscript),
+ "",
+ )
+
+ def test_head_with_base(self):
+ """Test head element with base tag."""
+ head = Head(Base(href="https://www.example.com/"), Title("My Page"))
+ self.assertIn("Content')
+ def test_article(self):
+ """Test the creation of an article element with text content."""
+ article = Article("This is an article")
+ self.assertEqual(article.tag, "article")
+ self.assertEqual(str(article), "This is an article")
+
+ def test_aside(self):
+ """Test the creation of an aside element with text content."""
+ aside = Aside("Sidebar content")
+ self.assertEqual(aside.tag, "aside")
+ self.assertEqual(str(aside), "")
+
+ def test_details(self):
+ """Test the creation of a details element."""
+ details = Details("Hidden content")
+ self.assertEqual(details.tag, "details")
+ self.assertEqual(str(details), "Hidden content")
+
+ def test_summary(self):
+ """Test the creation of a summary element."""
+ summary = Summary("Click to expand")
+ self.assertEqual(summary.tag, "summary")
+ self.assertEqual(str(summary), "Click to expand")
+
+ def test_details_with_summary(self):
+ """Test details element with nested summary."""
+ details = Details(
+ Summary("More information"), Paragraph("This is the hidden content")
+ )
+ expected = "More information
This is the hidden content
"
+ self.assertEqual(str(details), expected)
+
+ def test_dialog(self):
+ """Test the creation of a dialog element."""
+ dialog = Dialog("Dialog content")
+ self.assertEqual(dialog.tag, "dialog")
+ self.assertEqual(str(dialog), "")
+
+ def test_dialog_with_open_attribute(self):
+ """Test dialog element with open attribute."""
+ dialog = Dialog("Dialog is open", open="open")
+ self.assertIn('open="open"', str(dialog))
+
def test_inheritance(self):
"""Test that all layout-related classes inherit from HTMLElement."""
- for cls in [Div, Section, Header, Nav, Footer, HorizontalRule, Main]:
+ for cls in [
+ Div,
+ Section,
+ Article,
+ Aside,
+ Header,
+ Nav,
+ Footer,
+ HorizontalRule,
+ Main,
+ Details,
+ Summary,
+ Dialog,
+ ]:
self.assertTrue(issubclass(cls, HTMLElement))
diff --git a/tests/test_media.py b/tests/test_media.py
index 1e1f46c..57e46fd 100644
--- a/tests/test_media.py
+++ b/tests/test_media.py
@@ -6,10 +6,16 @@
Video,
Audio,
Source,
+ Track,
Picture,
Figure,
Figcaption,
Canvas,
+ Embed,
+ Object,
+ Param,
+ Map,
+ Area,
)
@@ -96,9 +102,100 @@ def test_attributes(self):
'',
)
+ def test_track(self):
+ """Test the creation of a track element."""
+ track = Track(
+ kind="subtitles", src="subtitles_en.vtt", srclang="en", label="English"
+ )
+ self.assertEqual(track.tag, "track")
+ self.assertTrue(track.self_closing)
+ self.assertIn('kind="subtitles"', str(track))
+ self.assertIn('src="subtitles_en.vtt"', str(track))
+
+ def test_video_with_track(self):
+ """Test video element with track for subtitles."""
+ video = Video(
+ Source(src="video.mp4", type="video/mp4"),
+ Track(kind="subtitles", src="subtitles_en.vtt", srclang="en"),
+ controls="controls",
+ )
+ self.assertIn("
'
)
+ def test_bold(self):
+ """Test the creation of a bold element."""
+ bold = Bold("Bold text")
+ self.assertEqual(bold.tag, "b")
+ self.assertEqual(str(bold), "Bold text")
+
+ def test_del(self):
+ """Test the creation of a del element."""
+ deleted = Del("Deleted text")
+ self.assertEqual(deleted.tag, "del")
+ self.assertEqual(str(deleted), "Deleted text")
+
+ def test_ins(self):
+ """Test the creation of an ins element."""
+ inserted = Ins("Inserted text")
+ self.assertEqual(inserted.tag, "ins")
+ self.assertEqual(str(inserted), "Inserted text")
+
+ def test_strikethrough(self):
+ """Test the creation of a strikethrough element."""
+ strike = Strikethrough("Strikethrough text")
+ self.assertEqual(strike.tag, "s")
+ self.assertEqual(str(strike), "Strikethrough text")
+
+ def test_underline(self):
+ """Test the creation of an underline element."""
+ underline = Underline("Underlined text")
+ self.assertEqual(underline.tag, "u")
+ self.assertEqual(str(underline), "Underlined text")
+
+ def test_kbd(self):
+ """Test the creation of a kbd element."""
+ kbd = Kbd("Ctrl+C")
+ self.assertEqual(kbd.tag, "kbd")
+ self.assertEqual(str(kbd), "Ctrl+C")
+
+ def test_samp(self):
+ """Test the creation of a samp element."""
+ samp = Samp("Sample output")
+ self.assertEqual(samp.tag, "samp")
+ self.assertEqual(str(samp), "Sample output")
+
+ def test_var(self):
+ """Test the creation of a var element."""
+ var = Var("x")
+ self.assertEqual(var.tag, "var")
+ self.assertEqual(str(var), "x")
+
+ def test_mark(self):
+ """Test the creation of a mark element."""
+ mark = Mark("Highlighted text")
+ self.assertEqual(mark.tag, "mark")
+ self.assertEqual(str(mark), "Highlighted text")
+
+ def test_dfn(self):
+ """Test the creation of a dfn element."""
+ dfn = Dfn("Definition term")
+ self.assertEqual(dfn.tag, "dfn")
+ self.assertEqual(str(dfn), "Definition term")
+
+ def test_br(self):
+ """Test the creation of a br element."""
+ br = Br()
+ self.assertEqual(br.tag, "br")
+ self.assertTrue(br.self_closing)
+ self.assertEqual(str(br), " ")
+
+ def test_wbr(self):
+ """Test the creation of a wbr element."""
+ wbr = Wbr()
+ self.assertEqual(wbr.tag, "wbr")
+ self.assertTrue(wbr.self_closing)
+ self.assertEqual(str(wbr), "")
+
def test_inheritance(self):
"""Test that all text-related classes inherit from HTMLElement."""
for cls in [
@@ -199,6 +285,7 @@ def test_inheritance(self):
Italic,
Span,
Strong,
+ Bold,
Abbr,
Link,
Small,
@@ -206,6 +293,17 @@ def test_inheritance(self):
Subscript,
Time,
Code,
+ Del,
+ Ins,
+ Strikethrough,
+ Underline,
+ Kbd,
+ Samp,
+ Var,
+ Mark,
+ Dfn,
+ Br,
+ Wbr,
]:
self.assertTrue(issubclass(cls, HTMLElement))
From e98d3a86ce7dce3c15cbd2400efe687723ac4b62 Mon Sep 17 00:00:00 2001
From: Sean N
Date: Mon, 10 Nov 2025 21:06:37 +0200
Subject: [PATCH 3/7] Updating documentation & adding serialization methods
---
README.md | 121 ++++++++++++++++++++----
run_tests.py | 7 --
src/ydnatl/core/element.py | 62 ++++++++++++
tests/test_element.py | 187 ++++++++++++++++++++++++++++++++++++-
4 files changed, 349 insertions(+), 28 deletions(-)
delete mode 100644 run_tests.py
diff --git a/README.md b/README.md
index 74a2c00..0325f03 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,7 @@ YDNATL (**Y**ou **D**on't **N**eed **A**nother **T**emplate **L**anguage) is a P
- ✓ Declarative syntax for building HTML documents (like Flutter)
- ✓ Easy to read and write
- ✓ Supports all HTML5 elements
+- ✓ JSON serialization/deserialization for saving and loading UI structures
- ✓ Lightweight
- ✓ Extremely fast
- ✓ Fully customisable
@@ -38,7 +39,6 @@ page = HTML(
)
)
-# Render the HTML document
print(page.render())
```
@@ -106,16 +106,70 @@ div.add_attributes([("aria-label", "Main content"), ("tabindex", "0")])
# HTML output:
```
+### JSON Serialization
+
+YDNATL supports JSON serialization and deserialization, making it perfect for drag-and-drop website builders, saving UI states, or transmitting page structures over APIs.
+
+```python
+from ydnatl import *
+
+# Build a page structure
+page = Div(id="page", class_name="container")
+page.append(
+ H1("Welcome"),
+ Section(
+ Paragraph("This is a paragraph"),
+ Paragraph("Another paragraph", class_name="highlight")
+ )
+)
+
+# Serialize to JSON (for saving/storing)
+json_data = page.to_json(indent=2)
+print(json_data)
+
+# Later... deserialize from JSON (for loading)
+from ydnatl.core.element import HTMLElement
+restored_page = HTMLElement.from_json(json_data)
+
+# Generate HTML (output will be identical)
+print(str(restored_page))
+```
+
+The JSON format is simple and clean:
+
+```json
+{
+ "tag": "div",
+ "self_closing": false,
+ "attributes": {
+ "id": "page",
+ "class": "container"
+ },
+ "text": "",
+ "children": [...]
+}
+```
+
+**Use cases:**
+- Save/load website layouts to/from a database
+- Implement undo/redo functionality
+- Store pre-built templates as JSON
+- Version control for page structures
+- API communication between frontend and backend
+- Drag-and-drop website builders
+
## Great For
- CLI tools
-- Site builders
+- Drag-and-drop website builders
+- Site builders with save/load functionality
- Web frameworks
- Alternative to heavy template engines
- Static site generators
- Documentation generators
- LLM's and AI tooling that generate interfaces dynamically
- Creating frontends for headless platforms (CMS/CRM etc)
+- Applications requiring UI state serialization
## Examples
@@ -198,12 +252,6 @@ YDNATL has full test coverage. To run the tests locally, use:
pytest
```
-or:
-
-```shell
-python run_test.py
-```
-
## Element Methods:
- `instance.prepend()`
@@ -226,6 +274,9 @@ python run_test.py
- `instance.count_children()`
- `instance.render()`
- `instance.to_dict()`
+- `instance.to_json(indent=None)` - Serialize element to JSON string
+- `HTMLElement.from_dict(data)` - Reconstruct element from dictionary
+- `HTMLElement.from_json(json_str)` - Reconstruct element from JSON string
## Events
@@ -244,15 +295,15 @@ python run_test.py
## Modules
-| **Module** | **Purpose** | **Key Elements** |
-| ------------------ | --------------------------------- | ------------ |
-| ydnatl.tags.form | Common HTML form elements | Form, Input, Button, Select, Textarea |
-| ydnatl.tags.html | Structural HTML document elements | HTML, Head, Body, Title, Meta, Script |
-| ydnatl.tags.layout | Layout related HTML tags | Div, Section, Header, Nav, Footer, Main |
-| ydnatl.tags.lists | HTML list elements | UnorderedList, OrderedList, ListItem |
-| ydnatl.tags.media | Media related HTML elements | Image, Video, Audio, Figure, Canvas |
+| **Module** | **Purpose** | **Key Elements** |
+|--------------------|-----------------------------------|-------------------------------------------------|
+| ydnatl.tags.form | Common HTML form elements | Form, Input, Button, Select, Textarea |
+| ydnatl.tags.html | Structural HTML document elements | HTML, Head, Body, Title, Meta, Script |
+| ydnatl.tags.layout | Layout related HTML tags | Div, Section, Header, Nav, Footer, Main |
+| ydnatl.tags.lists | HTML list elements | UnorderedList, OrderedList, ListItem |
+| ydnatl.tags.media | Media related HTML elements | Image, Video, Audio, Figure, Canvas |
| ydnatl.tags.table | HTML table elements | Table, TableRow, TableHeaderCell, TableDataCell |
-| ydnatl.tags.text | HTML text elements | H1-H6, Paragraph, Span, Strong, Em |
+| ydnatl.tags.text | HTML text elements | H1-H6, Paragraph, Span, Strong, Em |
## Importing
@@ -279,7 +330,11 @@ from ydnatl.tags.text import H1, Paragraph
- `Option()`
- `Button()`
- `Fieldset()`
+- `Legend()`
- `Optgroup()`
+- `Output()`
+- `Progress()`
+- `Meter()`
#### ydnatl.tags.html
@@ -288,20 +343,27 @@ from ydnatl.tags.text import H1, Paragraph
- `Body()`
- `Title()`
- `Meta()`
+- `Base()`
- `HtmlLink()` (use instead of `Link()` to avoid conflicts)
- `Script()`
- `Style()`
+- `Noscript()`
- `IFrame()`
#### ydnatl.tags.layout
- `Div()`
- `Section()`
+- `Article()`
+- `Aside()`
- `Header()`
- `Nav()`
- `Footer()`
- `HorizontalRule()`
- `Main()`
+- `Details()`
+- `Summary()`
+- `Dialog()`
#### ydnatl.tags.lists
@@ -319,10 +381,16 @@ from ydnatl.tags.text import H1, Paragraph
- `Video()`
- `Audio()`
- `Source()`
+- `Track()`
- `Picture()`
- `Figure()`
- `Figcaption()`
- `Canvas()`
+- `Embed()`
+- `Object()`
+- `Param()`
+- `Map()`
+- `Area()`
#### ydnatl.tags.table
@@ -333,6 +401,9 @@ from ydnatl.tags.text import H1, Paragraph
- `TableBody()`
- `TableDataCell()`
- `TableRow()`
+- `Caption()`
+- `Col()`
+- `Colgroup()`
#### ydnatl.tags.text
@@ -351,6 +422,7 @@ from ydnatl.tags.text import H1, Paragraph
- `Italic()`
- `Span()`
- `Strong()`
+- `Bold()`
- `Abbr()`
- `Link()`
- `Small()`
@@ -358,6 +430,17 @@ from ydnatl.tags.text import H1, Paragraph
- `Subscript()`
- `Time()`
- `Code()`
+- `Del()`
+- `Ins()`
+- `Strikethrough()`
+- `Underline()`
+- `Kbd()`
+- `Samp()`
+- `Var()`
+- `Mark()`
+- `Dfn()`
+- `Br()`
+- `Wbr()`
## Creating your own elements or components
@@ -403,9 +486,9 @@ This will produce:
```
-You can use the event callbacks or properties/methods directly to load further child elements, fetch data or any other programmatic task to enrich or contruct your tag on loading, render or even after render.
+You can use the event callbacks or properties/methods directly to load further child elements, fetch data or any other programmatic task to enrich or construct your tag on loading, render or even after render.
-You can take this further and contruct an entire page as a component where everything needed for the page is contained within the element class itself. This is a great way to build websites.
+You can take this further and construct an entire page as a component where everything needed for the page is contained within the element class itself. This is a great way to build websites.
## Contributions
@@ -430,8 +513,6 @@ pip install ".[dev]"
3. Run the tests:
```bash
-python run_tests.py
-# or
pytest
```
diff --git a/run_tests.py b/run_tests.py
deleted file mode 100644
index 762da2b..0000000
--- a/run_tests.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import unittest
-
-if __name__ == "__main__":
- test_loader = unittest.TestLoader()
- test_suite = test_loader.discover("tests")
- test_runner = unittest.TextTestRunner()
- test_runner.run(test_suite)
diff --git a/src/ydnatl/core/element.py b/src/ydnatl/core/element.py
index 8e7c75a..d7127c6 100644
--- a/src/ydnatl/core/element.py
+++ b/src/ydnatl/core/element.py
@@ -1,5 +1,6 @@
import copy
import html
+import json
import os
import uuid
from typing import Callable, Any, Iterator, Union, List
@@ -238,6 +239,7 @@ def _render_attributes(self) -> str:
def render(self) -> str:
"""Renders the HTML element and its children to a string."""
self.on_before_render()
+
attributes = self._render_attributes()
tag_start = f"<{self._tag}{attributes}"
@@ -262,3 +264,63 @@ def to_dict(self) -> dict:
"text": self._text,
"children": list(map(lambda child: child.to_dict(), self._children)),
}
+
+ def to_json(self, indent: int = None) -> str:
+ """Serializes the element and its children to a JSON string.
+
+ Args:
+ indent: Number of spaces for JSON indentation (None for compact output)
+
+ Returns:
+ JSON string representation of the element
+ """
+ return json.dumps(self.to_dict(), indent=indent)
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "HTMLElement":
+ """Reconstructs an HTMLElement from a dictionary.
+
+ Args:
+ data: Dictionary containing element data (from to_dict())
+
+ Returns:
+ Reconstructed HTMLElement instance
+ """
+ if not isinstance(data, dict):
+ raise ValueError("Input must be a dictionary")
+
+ if "tag" not in data:
+ raise ValueError("Dictionary must contain 'tag' key")
+
+ element = cls(
+ tag=data["tag"],
+ self_closing=data.get("self_closing", False),
+ **data.get("attributes", {})
+ )
+
+ if "text" in data and data["text"]:
+ element._text = data["text"]
+
+ if "children" in data and data["children"]:
+ for child_data in data["children"]:
+ child = cls.from_dict(child_data)
+ element._children.append(child)
+
+ return element
+
+ @classmethod
+ def from_json(cls, json_str: str) -> "HTMLElement":
+ """Reconstructs an HTMLElement from a JSON string.
+
+ Args:
+ json_str: JSON string representation (from to_json())
+
+ Returns:
+ Reconstructed HTMLElement instance
+ """
+ try:
+ data = json.loads(json_str)
+ except json.JSONDecodeError as e:
+ raise ValueError(f"Invalid JSON string: {e}")
+
+ return cls.from_dict(data)
diff --git a/tests/test_element.py b/tests/test_element.py
index 1b5e007..191362e 100644
--- a/tests/test_element.py
+++ b/tests/test_element.py
@@ -189,7 +189,6 @@ def test_add_attributes(self):
element.add_attributes([("id", "my-div"), ("class", "container")])
self.assertEqual(element.attributes, {"id": "my-div", "class": "container"})
- # Test overwriting existing attributes
element.add_attributes([("id", "new-id"), ("style", "color: red")])
self.assertEqual(
element.attributes,
@@ -244,6 +243,192 @@ def test_html_escaping_normal_content(self):
rendered = str(element)
self.assertEqual(rendered, f"
{normal_text}
")
+ def test_to_json_simple_element(self):
+ """Test serialization of a simple element to JSON."""
+ import json
+ element = HTMLElement("Hello", tag="div", id="test", class_name="container")
+ json_str = element.to_json()
+ self.assertIsInstance(json_str, str)
+ data = json.loads(json_str)
+ self.assertEqual(data["tag"], "div")
+ self.assertEqual(data["text"], "Hello")
+ self.assertEqual(data["attributes"]["id"], "test")
+
+ def test_to_json_with_indent(self):
+ """Test JSON serialization with indentation."""
+ element = HTMLElement("Test", tag="p")
+ json_str = element.to_json(indent=2)
+ self.assertIn("\n", json_str) # Should have newlines with indentation
+ self.assertIn(" ", json_str) # Should have spaces for indentation
+
+ def test_to_json_nested_elements(self):
+ """Test serialization of nested elements to JSON."""
+ parent = HTMLElement(tag="div", id="parent")
+ child1 = HTMLElement("Child 1", tag="span")
+ child2 = HTMLElement("Child 2", tag="span")
+ parent.append(child1, child2)
+
+ json_str = parent.to_json()
+ import json
+ data = json.loads(json_str)
+
+ self.assertEqual(len(data["children"]), 2)
+ self.assertEqual(data["children"][0]["text"], "Child 1")
+ self.assertEqual(data["children"][1]["text"], "Child 2")
+
+ def test_from_dict_simple_element(self):
+ """Test reconstruction of a simple element from dictionary."""
+ data = {
+ "tag": "div",
+ "self_closing": False,
+ "attributes": {"id": "test", "class": "container"},
+ "text": "Hello",
+ "children": []
+ }
+ element = HTMLElement.from_dict(data)
+
+ self.assertEqual(element.tag, "div")
+ self.assertEqual(element.text, "Hello")
+ self.assertEqual(element.get_attribute("id"), "test")
+ self.assertEqual(element.get_attribute("class"), "container")
+ self.assertFalse(element.self_closing)
+
+ def test_from_dict_self_closing_element(self):
+ """Test reconstruction of a self-closing element."""
+ data = {
+ "tag": "br",
+ "self_closing": True,
+ "attributes": {},
+ "text": "",
+ "children": []
+ }
+ element = HTMLElement.from_dict(data)
+
+ self.assertEqual(element.tag, "br")
+ self.assertTrue(element.self_closing)
+ self.assertEqual(str(element), " ")
+
+ def test_from_dict_nested_elements(self):
+ """Test reconstruction of nested elements from dictionary."""
+ data = {
+ "tag": "div",
+ "self_closing": False,
+ "attributes": {"id": "parent"},
+ "text": "",
+ "children": [
+ {
+ "tag": "span",
+ "self_closing": False,
+ "attributes": {},
+ "text": "Child 1",
+ "children": []
+ },
+ {
+ "tag": "span",
+ "self_closing": False,
+ "attributes": {},
+ "text": "Child 2",
+ "children": []
+ }
+ ]
+ }
+ element = HTMLElement.from_dict(data)
+
+ self.assertEqual(element.tag, "div")
+ self.assertEqual(len(element.children), 2)
+ self.assertEqual(element.children[0].text, "Child 1")
+ self.assertEqual(element.children[1].text, "Child 2")
+
+ def test_from_dict_invalid_input(self):
+ """Test from_dict with invalid input."""
+ with self.assertRaises(ValueError) as context:
+ HTMLElement.from_dict("not a dict")
+ self.assertIn("must be a dictionary", str(context.exception))
+
+ def test_from_dict_missing_tag(self):
+ """Test from_dict with missing tag key."""
+ data = {"attributes": {}, "text": "Test"}
+ with self.assertRaises(ValueError) as context:
+ HTMLElement.from_dict(data)
+ self.assertIn("must contain 'tag' key", str(context.exception))
+
+ def test_from_json_simple_element(self):
+ """Test reconstruction from JSON string."""
+ json_str = '{"tag": "p", "self_closing": false, "attributes": {"id": "test"}, "text": "Hello", "children": []}'
+ element = HTMLElement.from_json(json_str)
+
+ self.assertEqual(element.tag, "p")
+ self.assertEqual(element.text, "Hello")
+ self.assertEqual(element.get_attribute("id"), "test")
+
+ def test_from_json_invalid_json(self):
+ """Test from_json with invalid JSON string."""
+ invalid_json = "{invalid json}"
+ with self.assertRaises(ValueError) as context:
+ HTMLElement.from_json(invalid_json)
+ self.assertIn("Invalid JSON string", str(context.exception))
+
+ def test_round_trip_serialization(self):
+ """Test that serialization and deserialization preserve element structure."""
+ original = HTMLElement(tag="div", id="container", class_name="wrapper")
+ section = HTMLElement(tag="section")
+ section.append(
+ HTMLElement("Paragraph 1", tag="p"),
+ HTMLElement("Paragraph 2", tag="p", class_name="highlight")
+ )
+ original.append(
+ HTMLElement("Header", tag="h1"),
+ section
+ )
+
+ json_str = original.to_json()
+
+ restored = HTMLElement.from_json(json_str)
+
+ self.assertEqual(original.tag, restored.tag)
+ self.assertEqual(original.get_attribute("id"), restored.get_attribute("id"))
+ self.assertEqual(len(original.children), len(restored.children))
+ self.assertEqual(original.children[0].text, restored.children[0].text)
+
+ self.assertEqual(str(original), str(restored))
+
+ def test_round_trip_with_attributes(self):
+ """Test round-trip serialization with various attributes."""
+ original = HTMLElement(
+ tag="input",
+ self_closing=True,
+ type="text",
+ name="username",
+ placeholder="Enter username",
+ data_validation="required"
+ )
+
+ json_str = original.to_json()
+ restored = HTMLElement.from_json(json_str)
+
+ self.assertEqual(original.get_attribute("type"), restored.get_attribute("type"))
+ self.assertEqual(original.get_attribute("name"), restored.get_attribute("name"))
+ self.assertEqual(original.get_attribute("placeholder"), restored.get_attribute("placeholder"))
+ self.assertEqual(original.get_attribute("data-validation"), restored.get_attribute("data-validation"))
+ self.assertEqual(str(original), str(restored))
+
+ def test_serialization_deeply_nested(self):
+ """Test serialization of deeply nested structures."""
+ root = HTMLElement(tag="div", id="root")
+ level1 = HTMLElement(tag="div", id="level1")
+ level2 = HTMLElement(tag="div", id="level2")
+ level3 = HTMLElement("Deep content", tag="span")
+
+ level2.append(level3)
+ level1.append(level2)
+ root.append(level1)
+
+ json_str = root.to_json()
+ restored = HTMLElement.from_json(json_str)
+
+ self.assertEqual(restored.children[0].children[0].children[0].text, "Deep content")
+ self.assertEqual(str(root), str(restored))
+
if __name__ == "__main__":
unittest.main()
From 3ae2cb418f442e272bed6091d6376dff36b01500 Mon Sep 17 00:00:00 2001
From: Sean N
Date: Mon, 10 Nov 2025 21:09:52 +0200
Subject: [PATCH 4/7] Bumping up version and cleanups
---
pyproject.toml | 4 ++--
src/ydnatl/core/factory.py | 2 +-
src/ydnatl/tags/tag_factory.py | 4 ++--
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index a613f86..6747909 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "ydnatl"
-version = "1.0.5"
-description = "YDNATL is a Python library that lets you build HTML using simple Python classes."
+version = "1.0.6"
+description = "YDNATL is a Python library that lets you build HTML UI using simple Python classes."
readme = "README.md"
requires-python = ">=3.8"
license-files = ["LICEN[CS]E*"]
diff --git a/src/ydnatl/core/factory.py b/src/ydnatl/core/factory.py
index 9ce522b..5235fad 100644
--- a/src/ydnatl/core/factory.py
+++ b/src/ydnatl/core/factory.py
@@ -4,4 +4,4 @@ def __init__(self, string):
@staticmethod
def inflate(self, data: dict):
- return NotImplementedError("Not implemented")
+ return NotImplementedError("Not implemented - future release.")
diff --git a/src/ydnatl/tags/tag_factory.py b/src/ydnatl/tags/tag_factory.py
index efe540b..7db3142 100644
--- a/src/ydnatl/tags/tag_factory.py
+++ b/src/ydnatl/tags/tag_factory.py
@@ -3,9 +3,9 @@
# Factory function to create simple tag classes
def simple_tag_class(tag, self_closing=False, extra_init=None):
- # Add validation
if not isinstance(tag, str):
raise TypeError(f"tag must be a string, got {type(tag)}")
+
if extra_init is not None and not callable(extra_init):
raise TypeError("extra_init must be callable")
@@ -21,7 +21,7 @@ def __init__(self, *args, **kwargs):
**({"self_closing": True} if self_closing else {}),
},
)
-
+ # @NOTE __qualname__ for serialization
class_name = tag.capitalize() if tag.islower() else tag
_Tag.__name__ = class_name
_Tag.__qualname__ = class_name
From c4fda0e2093d94a49f4391d5d2cce6bbc9db52e6 Mon Sep 17 00:00:00 2001
From: Sean N
Date: Mon, 10 Nov 2025 21:20:41 +0200
Subject: [PATCH 5/7] Improvements + additional builder methods that are useful
---
src/ydnatl/__init__.py | 85 ++++++++++++++-
src/ydnatl/core/element.py | 217 +++++++++++++++++++++++++++++++++----
2 files changed, 279 insertions(+), 23 deletions(-)
diff --git a/src/ydnatl/__init__.py b/src/ydnatl/__init__.py
index 952975f..3bca13f 100644
--- a/src/ydnatl/__init__.py
+++ b/src/ydnatl/__init__.py
@@ -9,6 +9,10 @@
Input,
Label,
Optgroup,
+ Legend,
+ Output,
+ Progress,
+ Meter,
)
from .tags.html import (
HTML,
@@ -20,8 +24,23 @@
Script,
Style,
IFrame,
+ Base,
+ Noscript,
+)
+from .tags.layout import (
+ Div,
+ Section,
+ Header,
+ Nav,
+ Footer,
+ HorizontalRule,
+ Main,
+ Article,
+ Aside,
+ Details,
+ Summary,
+ Dialog,
)
-from .tags.layout import Div, Section, Header, Nav, Footer, HorizontalRule, Main
from .tags.lists import (
UnorderedList,
OrderedList,
@@ -31,7 +50,22 @@
DescriptionList,
DescriptionTerm,
)
-from .tags.media import Image, Video, Audio, Source, Picture, Figure, Figcaption, Canvas
+from .tags.media import (
+ Image,
+ Video,
+ Audio,
+ Source,
+ Picture,
+ Figure,
+ Figcaption,
+ Canvas,
+ Track,
+ Embed,
+ Object,
+ Param,
+ Map,
+ Area,
+)
from .tags.table import (
Table,
TableFooter,
@@ -40,6 +74,9 @@
TableBody,
TableDataCell,
TableRow,
+ Caption,
+ Col,
+ Colgroup,
)
from .tags.text import (
H1,
@@ -64,6 +101,18 @@
Subscript,
Time,
Code,
+ Bold,
+ Del,
+ Ins,
+ Strikethrough,
+ Underline,
+ Kbd,
+ Samp,
+ Var,
+ Mark,
+ Dfn,
+ Br,
+ Wbr,
)
__all__ = [
@@ -78,6 +127,10 @@
"Input",
"Label",
"Optgroup",
+ "Legend",
+ "Output",
+ "Progress",
+ "Meter",
# html
"HTML",
"Head",
@@ -88,6 +141,8 @@
"Script",
"Style",
"IFrame",
+ "Base",
+ "Noscript",
# layout
"Div",
"Section",
@@ -96,6 +151,11 @@
"Footer",
"HorizontalRule",
"Main",
+ "Article",
+ "Aside",
+ "Details",
+ "Summary",
+ "Dialog",
# lists
"UnorderedList",
"OrderedList",
@@ -113,6 +173,12 @@
"Figure",
"Figcaption",
"Canvas",
+ "Track",
+ "Embed",
+ "Object",
+ "Param",
+ "Map",
+ "Area",
# table
"Table",
"TableFooter",
@@ -121,6 +187,9 @@
"TableBody",
"TableDataCell",
"TableRow",
+ "Caption",
+ "Col",
+ "Colgroup",
# text
"H1",
"H2",
@@ -144,4 +213,16 @@
"Subscript",
"Time",
"Code",
+ "Bold",
+ "Del",
+ "Ins",
+ "Strikethrough",
+ "Underline",
+ "Kbd",
+ "Samp",
+ "Var",
+ "Mark",
+ "Dfn",
+ "Br",
+ "Wbr",
]
diff --git a/src/ydnatl/core/element.py b/src/ydnatl/core/element.py
index d7127c6..fdf97f1 100644
--- a/src/ydnatl/core/element.py
+++ b/src/ydnatl/core/element.py
@@ -3,7 +3,9 @@
import json
import os
import uuid
-from typing import Callable, Any, Iterator, Union, List
+from typing import Callable, Any, Iterator, Union, List, TypeVar
+
+T = TypeVar('T', bound='HTMLElement')
class HTMLElement:
@@ -46,6 +48,14 @@ def __str__(self) -> str:
def __del__(self) -> None:
self.on_unload()
+ def __enter__(self) -> "HTMLElement":
+ """Context manager entry - returns self for use in with statements."""
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ """Context manager exit - no cleanup needed."""
+ pass
+
@staticmethod
def _flatten(items: Union[List[Any], tuple]) -> Iterator[Any]:
"""Recursively flattens nested iterables of children."""
@@ -62,8 +72,12 @@ def _add_child(self, child: Union["HTMLElement", str]) -> None:
elif isinstance(child, str):
self._text += child
- def prepend(self, *children: Union["HTMLElement", str, List[Any]]) -> None:
- """Prepends children to the current tag."""
+ def prepend(self, *children: Union["HTMLElement", str, List[Any]]) -> "HTMLElement":
+ """Prepends children to the current tag.
+
+ Returns:
+ self for method chaining
+ """
new_children: List[HTMLElement] = []
for child in self._flatten(children):
if isinstance(child, HTMLElement):
@@ -73,11 +87,17 @@ def prepend(self, *children: Union["HTMLElement", str, List[Any]]) -> None:
else:
raise ValueError(f"Invalid child type: {child}")
self._children = new_children + self._children
+ return self
- def append(self, *children: Union["HTMLElement", str, List[Any]]) -> None:
- """Appends children to the current tag."""
+ def append(self, *children: Union["HTMLElement", str, List[Any]]) -> "HTMLElement":
+ """Appends children to the current tag.
+
+ Returns:
+ self for method chaining
+ """
for child in self._flatten(children):
self._add_child(child)
+ return self
def filter(
self, condition: Callable[[Any], bool], recursive: bool = False
@@ -89,16 +109,26 @@ def filter(
if recursive:
yield from child.filter(condition, recursive=True)
- def remove_all(self, condition: Callable[[Any], bool]) -> None:
- """Removes all children that meet the condition."""
+ def remove_all(self, condition: Callable[[Any], bool]) -> "HTMLElement":
+ """Removes all children that meet the condition.
+
+ Returns:
+ self for method chaining
+ """
to_remove = list(self.filter(condition))
for child in to_remove:
if child in self._children:
self._children.remove(child)
+ return self
+
+ def clear(self) -> "HTMLElement":
+ """Clears all children from the tag.
- def clear(self) -> None:
- """Clears all children from the tag."""
+ Returns:
+ self for method chaining
+ """
self._children.clear()
+ return self
def pop(self, index: int = 0) -> "HTMLElement":
"""Pops a child from the tag."""
@@ -112,18 +142,33 @@ def last(self) -> Union["HTMLElement", None]:
"""Returns the last child of the tag."""
return self._children[-1] if self._children else None
- def add_attribute(self, key: str, value: str) -> None:
- """Adds an attribute to the current tag."""
+ def add_attribute(self, key: str, value: str) -> "HTMLElement":
+ """Adds an attribute to the current tag.
+
+ Returns:
+ self for method chaining
+ """
self._attributes[key] = value
+ return self
+
+ def add_attributes(self, attributes: list[tuple[str, str]]) -> "HTMLElement":
+ """Adds multiple attributes to the current tag.
- def add_attributes(self, attributes: list[tuple[str, str]]) -> None:
- """Adds multiple attributes to the current tag."""
+ Returns:
+ self for method chaining
+ """
for key, value in attributes:
self._attributes[key] = value
+ return self
+
+ def remove_attribute(self, key: str) -> "HTMLElement":
+ """Removes an attribute from the current tag.
- def remove_attribute(self, key: str) -> None:
- """Removes an attribute from the current tag."""
+ Returns:
+ self for method chaining
+ """
self._attributes.pop(key, None)
+ return self
def get_attribute(self, key: str) -> Union[str, None]:
"""Gets an attribute from the current tag."""
@@ -133,6 +178,102 @@ def has_attribute(self, key: str) -> bool:
"""Checks if an attribute exists in the current tag."""
return key in self._attributes
+ def add_style(self, key: str, value: str) -> "HTMLElement":
+ """Adds a CSS style to the element's inline styles.
+
+ Args:
+ key: CSS property name (e.g., 'color', 'font-size')
+ value: CSS property value (e.g., 'red', '14px')
+
+ Returns:
+ self for method chaining
+ """
+ current_style = self._attributes.get("style", "")
+ styles_dict = self._parse_styles(current_style)
+ styles_dict[key] = value
+ self._attributes["style"] = self._format_styles(styles_dict)
+ return self
+
+ def add_styles(self, styles: dict) -> "HTMLElement":
+ """Adds multiple CSS styles to the element's inline styles.
+
+ Args:
+ styles: Dictionary of CSS properties and values
+ e.g., {"color": "red", "font-size": "14px"}
+
+ Returns:
+ self for method chaining
+ """
+ current_style = self._attributes.get("style", "")
+ styles_dict = self._parse_styles(current_style)
+ styles_dict.update(styles)
+ self._attributes["style"] = self._format_styles(styles_dict)
+ return self
+
+ def get_style(self, key: str) -> Union[str, None]:
+ """Gets a specific CSS style value from the element's inline styles.
+
+ Args:
+ key: CSS property name
+
+ Returns:
+ The CSS property value or None if not found
+ """
+ current_style = self._attributes.get("style", "")
+ styles_dict = self._parse_styles(current_style)
+ return styles_dict.get(key)
+
+ def remove_style(self, key: str) -> "HTMLElement":
+ """Removes a CSS style from the element's inline styles.
+
+ Args:
+ key: CSS property name to remove
+
+ Returns:
+ self for method chaining
+ """
+ current_style = self._attributes.get("style", "")
+ styles_dict = self._parse_styles(current_style)
+ styles_dict.pop(key, None)
+ if styles_dict:
+ self._attributes["style"] = self._format_styles(styles_dict)
+ else:
+ self._attributes.pop("style", None)
+ return self
+
+ @staticmethod
+ def _parse_styles(style_str: str) -> dict:
+ """Parses a CSS style string into a dictionary.
+
+ Args:
+ style_str: CSS style string (e.g., "color: red; font-size: 14px")
+
+ Returns:
+ Dictionary of CSS properties and values
+ """
+ if not style_str:
+ return {}
+
+ styles = {}
+ for style in style_str.split(";"):
+ style = style.strip()
+ if ":" in style:
+ key, value = style.split(":", 1)
+ styles[key.strip()] = value.strip()
+ return styles
+
+ @staticmethod
+ def _format_styles(styles_dict: dict) -> str:
+ """Formats a dictionary of styles into a CSS style string.
+
+ Args:
+ styles_dict: Dictionary of CSS properties and values
+
+ Returns:
+ CSS style string (e.g., "color: red; font-size: 14px")
+ """
+ return "; ".join(f"{k}: {v}" for k, v in styles_dict.items())
+
def generate_id(self) -> None:
"""Generates an id for the current tag if not already present."""
if "id" not in self._attributes:
@@ -236,19 +377,53 @@ def _render_attributes(self) -> str:
)
return f" {attr_str}" if attr_str else ""
- def render(self) -> str:
- """Renders the HTML element and its children to a string."""
+ def render(self, pretty: bool = False, _indent: int = 0) -> str:
+ """Renders the HTML element and its children to a string.
+
+ Args:
+ pretty: If True, renders with indentation and newlines for readability
+ _indent: Internal parameter for tracking indentation level
+
+ Returns:
+ String representation of the HTML element
+ """
self.on_before_render()
attributes = self._render_attributes()
- tag_start = f"<{self._tag}{attributes}"
+ indent_str = " " * _indent if pretty else ""
+ tag_start = f"{indent_str}<{self._tag}{attributes}"
if self._self_closing:
result = f"{tag_start} />"
+ if pretty:
+ result += "\n"
else:
- children_html = "".join(child.render() for child in self._children)
- escaped_text = html.escape(self._text)
- result = f"{tag_start}>{escaped_text}{children_html}{self._tag}>"
+ # Render children with increased indentation
+ if pretty and self._children:
+ children_html = "".join(
+ child.render(pretty=True, _indent=_indent + 1)
+ for child in self._children
+ )
+ escaped_text = html.escape(self._text)
+
+ # Add newlines and indentation for readability
+ if self._children or self._text:
+ result = f"{tag_start}>"
+ if escaped_text:
+ result += escaped_text
+ if self._children:
+ result += "\n" + children_html + indent_str
+ result += f"{self._tag}>\n"
+ else:
+ result = f"{tag_start}>{self._tag}>\n"
+ else:
+ # Non-pretty or inline rendering
+ children_html = "".join(
+ child.render(pretty=pretty, _indent=_indent + 1)
+ for child in self._children
+ )
+ escaped_text = html.escape(self._text)
+ result = f"{tag_start}>{escaped_text}{children_html}{self._tag}>"
if hasattr(self, "_prefix") and self._prefix:
result = f"{self._prefix}{result}"
From 986656c55a7bfc1004ba2831fa60fb3d73359430 Mon Sep 17 00:00:00 2001
From: Sean N
Date: Mon, 10 Nov 2025 21:22:46 +0200
Subject: [PATCH 6/7] Added: Support for Fragments to be able to group elements
without adding to the DOM
---
src/ydnatl/__init__.py | 2 ++
src/ydnatl/core/fragment.py | 53 +++++++++++++++++++++++++++++++++++++
2 files changed, 55 insertions(+)
create mode 100644 src/ydnatl/core/fragment.py
diff --git a/src/ydnatl/__init__.py b/src/ydnatl/__init__.py
index 3bca13f..423a5b8 100644
--- a/src/ydnatl/__init__.py
+++ b/src/ydnatl/__init__.py
@@ -1,4 +1,5 @@
from .core.element import HTMLElement
+from .core.fragment import Fragment
from .tags.form import (
Textarea,
Select,
@@ -117,6 +118,7 @@
__all__ = [
"HTMLElement",
+ "Fragment",
# form
"Textarea",
"Select",
diff --git a/src/ydnatl/core/fragment.py b/src/ydnatl/core/fragment.py
new file mode 100644
index 0000000..d779a15
--- /dev/null
+++ b/src/ydnatl/core/fragment.py
@@ -0,0 +1,53 @@
+from typing import Union, List, Any
+from ydnatl.core.element import HTMLElement
+
+
+class Fragment(HTMLElement):
+ """A container that renders only its children without wrapping tags.
+
+ Useful for grouping elements without introducing extra DOM nodes.
+
+ Example:
+ fragment = Fragment(
+ H1("Title"),
+ Paragraph("Content")
+ )
+ # Renders:
Title
Content
+ # Instead of:
Title
Content
+ """
+
+ def __init__(self, *children: Union["HTMLElement", str, List[Any]], **attributes: str):
+ # Initialize with a dummy tag name since we won't render it
+ super().__init__(*children, tag="fragment", **attributes)
+
+ def render(self, pretty: bool = False, _indent: int = 0) -> str:
+ """Renders only the children without the fragment wrapper.
+
+ Args:
+ pretty: If True, renders children with indentation
+ _indent: Internal parameter for tracking indentation level
+
+ Returns:
+ HTML string of all children concatenated
+ """
+ self.on_before_render()
+
+ # Render only children, not the fragment tag itself
+ if pretty:
+ result = "".join(
+ child.render(pretty=True, _indent=_indent)
+ for child in self._children
+ )
+ else:
+ result = "".join(
+ child.render(pretty=False, _indent=_indent)
+ for child in self._children
+ )
+
+ # Include text content if any
+ if self._text:
+ import html
+ result = html.escape(self._text) + result
+
+ self.on_after_render()
+ return result
\ No newline at end of file
From 81f82c4ad7cf8cf7d8f85e8a6ead112b0d0deea8 Mon Sep 17 00:00:00 2001
From: Sean N
Date: Mon, 10 Nov 2025 21:29:10 +0200
Subject: [PATCH 7/7] Added: unit tests for Fragments and updated docs
---
README.md | 236 ++++++++++++++++++++++++++++++----
tests/test_element.py | 261 +++++++++++++++++++++++++++++++++++++
tests/test_fragment.py | 284 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 758 insertions(+), 23 deletions(-)
create mode 100644 tests/test_fragment.py
diff --git a/README.md b/README.md
index 0325f03..968fd7c 100644
--- a/README.md
+++ b/README.md
@@ -6,10 +6,16 @@ YDNATL (**Y**ou **D**on't **N**eed **A**nother **T**emplate **L**anguage) is a P
- ✓ Easy to read and write
- ✓ Supports all HTML5 elements
- ✓ JSON serialization/deserialization for saving and loading UI structures
+- ✓ Pretty printing with indentation for readable HTML
+- ✓ CSS style helpers for easy inline styling
+- ✓ Method chaining for fluent interfaces
+- ✓ Context manager support for clean nesting
+- ✓ Fragment support for wrapper-free grouping
- ✓ Lightweight
- ✓ Extremely fast
- ✓ Fully customisable
- ✓ Compose HTML efficiently
+- ✓ Lean & clean Python with no dependencies
## Requirements
@@ -106,6 +112,178 @@ div.add_attributes([("aria-label", "Main content"), ("tabindex", "0")])
# HTML output:
```
+### Pretty Printing
+
+YDNATL supports pretty printing with automatic indentation for readable HTML output:
+
+```python
+from ydnatl import *
+
+page = HTML(
+ Head(Title("My Page")),
+ Body(
+ Div(
+ H1("Hello, World!"),
+ Paragraph("This is a paragraph.")
+ )
+ )
+)
+
+# Compact output (default)
+print(page.render())
+# Output: My Page...
+
+# Pretty output with indentation
+print(page.render(pretty=True))
+# Output:
+#
+#
+#
+# My Page
+#
+#
+#
+#
Hello, World!
+#
This is a paragraph.
+#
+#
+#
+```
+
+Pretty printing is perfect for:
+- Development and debugging
+- Generating human-readable HTML files
+- Documentation and tutorials
+- Inspecting complex structures
+
+### CSS Style Helpers
+
+YDNATL provides convenient methods for working with inline CSS styles:
+
+```python
+from ydnatl import *
+
+# Create element and add styles
+div = Div("Styled content")
+
+# Add single style
+div.add_style("color", "blue")
+div.add_style("font-size", "16px")
+
+# Add multiple styles at once
+div.add_styles({
+ "background-color": "#f0f0f0",
+ "padding": "20px",
+ "margin": "10px",
+ "border-radius": "5px"
+})
+
+# Get a specific style value
+color = div.get_style("color") # Returns "blue"
+
+# Remove a style
+div.remove_style("margin")
+
+# Result:
Styled content
+```
+
+### Method Chaining
+
+All builder methods return `self`, enabling fluent method chaining:
+
+```python
+from ydnatl import *
+
+# Chain multiple operations together
+container = (Div()
+ .add_attribute("id", "main-container")
+ .add_attribute("class", "wrapper")
+ .add_style("background", "#fff")
+ .add_styles({"padding": "20px", "margin": "0 auto"})
+ .append(H1("Welcome"))
+ .append(Paragraph("This is the main content."))
+ .append(Paragraph("Another paragraph here.")))
+
+print(container.render())
+```
+
+Chainable methods:
+- `append()` - Add children
+- `prepend()` - Add children at the beginning
+- `add_attribute()` - Add single attribute
+- `add_attributes()` - Add multiple attributes
+- `remove_attribute()` - Remove an attribute
+- `add_style()` - Add single CSS style
+- `add_styles()` - Add multiple CSS styles
+- `remove_style()` - Remove a CSS style
+- `clear()` - Remove all children
+- `remove_all()` - Remove children matching a condition
+
+### Context Manager Support
+
+Use elements as context managers for cleaner, more intuitive nesting:
+
+```python
+from ydnatl import *
+
+# Using context managers
+with Div(id="container", class_name="main") as container:
+ with Section(class_name="content") as section:
+ section.append(H1("Title"))
+ section.append(Paragraph("Content goes here"))
+
+ container.append(section)
+ container.append(Footer(Paragraph("Footer text")))
+
+print(container.render())
+```
+
+### Fragment Support
+
+Use `Fragment` to group elements without adding a wrapper tag:
+
+```python
+from ydnatl import *
+
+# Without Fragment - adds extra div wrapper
+content = Div(
+ H1("Title"),
+ Paragraph("Content")
+)
+# Output:
Title
Content
+
+# With Fragment - no wrapper tag
+content = Fragment(
+ H1("Title"),
+ Paragraph("Content")
+)
+# Output:
Title
Content
+
+# Perfect for conditional rendering
+def render_items(items, show_header=True):
+ fragment = Fragment()
+
+ if show_header:
+ fragment.append(H2("Items List"))
+
+ for item in items:
+ fragment.append(Paragraph(item))
+
+ return fragment
+
+# Use in parent element
+page = Div(
+ render_items(["Item 1", "Item 2", "Item 3"], show_header=True)
+)
+# Output:
Items List
Item 1
Item 2
Item 3
+```
+
+**Fragment use cases:**
+- Conditional rendering without wrapper divs
+- Returning multiple elements from functions
+- List composition and iteration
+- Cleaner component architecture
+
### JSON Serialization
YDNATL supports JSON serialization and deserialization, making it perfect for drag-and-drop website builders, saving UI states, or transmitting page structures over APIs.
@@ -254,29 +432,41 @@ pytest
## Element Methods:
-- `instance.prepend()`
-- `instance.append()`
-- `instance.filter()`
-- `instance.remove_all()`
-- `instance.clear()`
-- `instance.pop()`
-- `instance.first()`
-- `instance.last()`
-- `instance.add_attribute()`
-- `instance.add_attributes()`
-- `instance.remove_attribute()`
-- `instance.get_attribute()`
-- `instance.has_attribute()`
-- `instance.generate_id()`
-- `instance.clone()`
-- `instance.find_by_attribute()`
-- `instance.get_attributes()`
-- `instance.count_children()`
-- `instance.render()`
-- `instance.to_dict()`
-- `instance.to_json(indent=None)` - Serialize element to JSON string
-- `HTMLElement.from_dict(data)` - Reconstruct element from dictionary
-- `HTMLElement.from_json(json_str)` - Reconstruct element from JSON string
+**Element Manipulation:**
+- `instance.prepend()` - Prepend children (returns self for chaining)
+- `instance.append()` - Append children (returns self for chaining)
+- `instance.filter()` - Filter children by condition
+- `instance.remove_all()` - Remove children matching condition (returns self for chaining)
+- `instance.clear()` - Remove all children (returns self for chaining)
+- `instance.pop()` - Remove and return child at index
+- `instance.first()` - Get first child
+- `instance.last()` - Get last child
+- `instance.replace_child()` - Replace child at index
+- `instance.clone()` - Deep copy of element
+- `instance.find_by_attribute()` - Find child by attribute value
+- `instance.count_children()` - Count direct children
+
+**Attribute Management:**
+- `instance.add_attribute()` - Add single attribute (returns self for chaining)
+- `instance.add_attributes()` - Add multiple attributes (returns self for chaining)
+- `instance.remove_attribute()` - Remove attribute (returns self for chaining)
+- `instance.get_attribute()` - Get attribute value
+- `instance.has_attribute()` - Check if attribute exists
+- `instance.get_attributes()` - Get all or specific attributes
+- `instance.generate_id()` - Generate unique ID if not present
+
+**CSS Style Management:**
+- `instance.add_style()` - Add single CSS style (returns self for chaining)
+- `instance.add_styles()` - Add multiple CSS styles (returns self for chaining)
+- `instance.get_style()` - Get specific style value
+- `instance.remove_style()` - Remove CSS style (returns self for chaining)
+
+**Rendering:**
+- `instance.render(pretty=False)` - Render to HTML string (pretty=True for indented output)
+- `instance.to_dict()` - Convert to dictionary
+- `instance.to_json(indent=None)` - Serialize to JSON string
+- `HTMLElement.from_dict(data)` - Reconstruct from dictionary (class method)
+- `HTMLElement.from_json(json_str)` - Reconstruct from JSON string (class method)
## Events
diff --git a/tests/test_element.py b/tests/test_element.py
index 191362e..382cd43 100644
--- a/tests/test_element.py
+++ b/tests/test_element.py
@@ -430,5 +430,266 @@ def test_serialization_deeply_nested(self):
self.assertEqual(str(root), str(restored))
+ def test_render_pretty_simple(self):
+ """Test pretty printing with simple nested structure."""
+ element = HTMLElement(tag="div", id="container")
+ element.append(
+ HTMLElement("Hello", tag="h1"),
+ HTMLElement("World", tag="p")
+ )
+
+ # Test compact (default)
+ compact = element.render(pretty=False)
+ self.assertNotIn("\n", compact)
+ self.assertEqual(compact, '
", rendered)
+ self.assertIn("", rendered)
+ self.assertNotIn("", rendered)
+
+ def test_fragment_with_text(self):
+ """Test Fragment with text content."""
+ fragment = Fragment("Plain text")
+
+ rendered = str(fragment)
+ self.assertEqual(rendered, "Plain text")
+
+ def test_fragment_mixed_text_and_elements(self):
+ """Test Fragment with both text and elements."""
+ fragment = Fragment(
+ "Text before",
+ Paragraph("Middle"),
+ "Text after"
+ )
+
+ # Note: text is handled differently, need to test actual output
+ rendered = str(fragment)
+ self.assertIn("
Middle
", rendered)
+
+ def test_fragment_pretty_printing(self):
+ """Test Fragment with pretty printing enabled."""
+ fragment = Fragment(
+ Div(
+ H1("Title"),
+ Paragraph("Content")
+ ),
+ Paragraph("Outside")
+ )
+
+ pretty = fragment.render(pretty=True)
+ self.assertIn("\n", pretty)
+ self.assertIn(" ", pretty) # Should have indentation from children
+ self.assertNotIn("", pretty)
+
+ def test_fragment_append_children(self):
+ """Test appending children to Fragment after creation."""
+ fragment = Fragment()
+ fragment.append(H1("Added Title"))
+ fragment.append(Paragraph("Added content"))
+
+ rendered = str(fragment)
+ self.assertEqual(rendered, "
Added Title
Added content
")
+
+ def test_fragment_prepend_children(self):
+ """Test prepending children to Fragment."""
+ fragment = Fragment(
+ Paragraph("Second")
+ )
+ fragment.prepend(H1("First"))
+
+ rendered = str(fragment)
+ self.assertEqual(rendered, "
First
Second
")
+
+ def test_fragment_clear(self):
+ """Test clearing Fragment children."""
+ fragment = Fragment(
+ H1("Title"),
+ Paragraph("Content")
+ )
+
+ fragment.clear()
+ rendered = str(fragment)
+ self.assertEqual(rendered, "")
+
+ def test_fragment_count_children(self):
+ """Test counting Fragment children."""
+ fragment = Fragment(
+ H1("Title"),
+ Paragraph("Content 1"),
+ Paragraph("Content 2")
+ )
+
+ self.assertEqual(fragment.count_children(), 3)
+
+ def test_fragment_nested_in_div(self):
+ """Test using Fragment inside another element."""
+ fragment = Fragment(
+ H1("Title"),
+ Paragraph("Content")
+ )
+
+ div = Div()
+ div.append(fragment)
+
+ rendered = str(div)
+ # Fragment should render its children without wrapper
+ self.assertEqual(rendered, "
Title
Content
")
+
+ def test_fragment_conditional_rendering(self):
+ """Test Fragment for conditional rendering use case."""
+ show_header = True
+ show_footer = False
+
+ fragment = Fragment()
+
+ if show_header:
+ fragment.append(H1("Header"))
+
+ fragment.append(Paragraph("Main content"))
+
+ if show_footer:
+ fragment.append(Paragraph("Footer"))
+
+ rendered = str(fragment)
+ self.assertIn("
Header
", rendered)
+ self.assertIn("
Main content
", rendered)
+ self.assertNotIn("Footer", rendered)
+
+ def test_fragment_list_composition(self):
+ """Test using Fragment to compose lists of elements."""
+ items = ["Item 1", "Item 2", "Item 3"]
+
+ fragment = Fragment()
+ for item in items:
+ fragment.append(Paragraph(item))
+
+ rendered = str(fragment)
+ self.assertEqual(rendered, "
", rendered)
+
+ def test_fragment_inheritance(self):
+ """Test that Fragment inherits from HTMLElement."""
+ fragment = Fragment()
+ self.assertIsInstance(fragment, HTMLElement)
+
+ def test_fragment_context_manager(self):
+ """Test using Fragment as a context manager."""
+ with Fragment() as fragment:
+ fragment.append(H1("Title"))
+ fragment.append(Paragraph("Content"))
+
+ rendered = str(fragment)
+ self.assertEqual(rendered, "