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/README.md b/README.md
index 74a2c00..968fd7c 100644
--- a/README.md
+++ b/README.md
@@ -5,10 +5,17 @@ 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
+- ✓ 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
@@ -38,7 +45,6 @@ page = HTML(
)
)
-# Render the HTML document
print(page.render())
```
@@ -106,16 +112,242 @@ 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.
+
+```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,34 +430,43 @@ YDNATL has full test coverage. To run the tests locally, use:
pytest
```
-or:
-
-```shell
-python run_test.py
-```
-
## 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()`
+**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
@@ -244,15 +485,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 +520,11 @@ from ydnatl.tags.text import H1, Paragraph
- `Option()`
- `Button()`
- `Fieldset()`
+- `Legend()`
- `Optgroup()`
+- `Output()`
+- `Progress()`
+- `Meter()`
#### ydnatl.tags.html
@@ -288,20 +533,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 +571,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 +591,9 @@ from ydnatl.tags.text import H1, Paragraph
- `TableBody()`
- `TableDataCell()`
- `TableRow()`
+- `Caption()`
+- `Col()`
+- `Colgroup()`
#### ydnatl.tags.text
@@ -351,6 +612,7 @@ from ydnatl.tags.text import H1, Paragraph
- `Italic()`
- `Span()`
- `Strong()`
+- `Bold()`
- `Abbr()`
- `Link()`
- `Small()`
@@ -358,6 +620,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 +676,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 +703,6 @@ pip install ".[dev]"
3. Run the tests:
```bash
-python run_tests.py
-# or
pytest
```
diff --git a/pyproject.toml b/pyproject.toml
index bf7fb64..6747909 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,13 +4,13 @@ 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*"]
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
deleted file mode 100644
index 8bbedc3..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)
\ No newline at end of file
diff --git a/src/ydnatl/__init__.py b/src/ydnatl/__init__.py
index 952975f..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,
@@ -9,6 +10,10 @@
Input,
Label,
Optgroup,
+ Legend,
+ Output,
+ Progress,
+ Meter,
)
from .tags.html import (
HTML,
@@ -20,8 +25,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 +51,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 +75,9 @@
TableBody,
TableDataCell,
TableRow,
+ Caption,
+ Col,
+ Colgroup,
)
from .tags.text import (
H1,
@@ -64,10 +102,23 @@
Subscript,
Time,
Code,
+ Bold,
+ Del,
+ Ins,
+ Strikethrough,
+ Underline,
+ Kbd,
+ Samp,
+ Var,
+ Mark,
+ Dfn,
+ Br,
+ Wbr,
)
__all__ = [
"HTMLElement",
+ "Fragment",
# form
"Textarea",
"Select",
@@ -78,6 +129,10 @@
"Input",
"Label",
"Optgroup",
+ "Legend",
+ "Output",
+ "Progress",
+ "Meter",
# html
"HTML",
"Head",
@@ -88,6 +143,8 @@
"Script",
"Style",
"IFrame",
+ "Base",
+ "Noscript",
# layout
"Div",
"Section",
@@ -96,6 +153,11 @@
"Footer",
"HorizontalRule",
"Main",
+ "Article",
+ "Aside",
+ "Details",
+ "Summary",
+ "Dialog",
# lists
"UnorderedList",
"OrderedList",
@@ -113,6 +175,12 @@
"Figure",
"Figcaption",
"Canvas",
+ "Track",
+ "Embed",
+ "Object",
+ "Param",
+ "Map",
+ "Area",
# table
"Table",
"TableFooter",
@@ -121,6 +189,9 @@
"TableBody",
"TableDataCell",
"TableRow",
+ "Caption",
+ "Col",
+ "Colgroup",
# text
"H1",
"H2",
@@ -144,4 +215,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 f93538f..fdf97f1 100644
--- a/src/ydnatl/core/element.py
+++ b/src/ydnatl/core/element.py
@@ -1,28 +1,32 @@
-import uuid
import copy
-import os
-import functools
import html
+import json
+import os
+import uuid
+from typing import Callable, Any, Iterator, Union, List, TypeVar
-from typing import Callable, Any, Iterator, Union, List
+T = TypeVar('T', bound='HTMLElement')
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,10 +44,18 @@ def __init__(
def __str__(self) -> str:
return self.render()
-
+
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."""
@@ -60,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):
@@ -71,14 +87,20 @@ 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]]) -> "HTMLElement":
+ """Appends children to the current tag.
- def append(self, *children: Union["HTMLElement", str, List[Any]]) -> None:
- """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
+ self, condition: Callable[[Any], bool], recursive: bool = False
) -> Iterator["HTMLElement"]:
"""Yields children (and optionally descendants) that meet the condition."""
for child in self._children:
@@ -87,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) -> None:
- """Clears all children from the tag."""
+ def clear(self) -> "HTMLElement":
+ """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."""
@@ -110,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
-
- def add_attributes(self, attributes: list[tuple[str, str]]) -> None:
- """Adds multiple attributes to the current tag."""
+ return self
+
+ def add_attributes(self, attributes: list[tuple[str, str]]) -> "HTMLElement":
+ """Adds multiple attributes to the current tag.
+
+ Returns:
+ self for method chaining
+ """
for key, value in attributes:
self._attributes[key] = value
-
- def remove_attribute(self, key: str) -> None:
- """Removes an attribute from the current tag."""
+ return self
+
+ def remove_attribute(self, key: str) -> "HTMLElement":
+ """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."""
@@ -131,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:
@@ -139,13 +282,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
@@ -231,30 +377,125 @@ 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}"
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)),
+ }
+
+ 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/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/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
diff --git a/src/ydnatl/tags/form.py b/src/ydnatl/tags/form.py
index 9e4df29..90fe941 100644
--- a/src/ydnatl/tags/form.py
+++ b/src/ydnatl/tags/form.py
@@ -1,14 +1,17 @@
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")
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):
@@ -45,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 2c9105f..69c001d 100644
--- a/src/ydnatl/tags/layout.py
+++ b/src/ydnatl/tags/layout.py
@@ -1,10 +1,14 @@
from ydnatl.tags.tag_factory import simple_tag_class
-
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 9a9ca1d..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 34647f6..f2c347e 100644
--- a/src/ydnatl/tags/media.py
+++ b/src/ydnatl/tags/media.py
@@ -1,11 +1,16 @@
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")
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 053c7b9..2eb922a 100644
--- a/src/ydnatl/tags/table.py
+++ b/src/ydnatl/tags/table.py
@@ -4,13 +4,15 @@
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")
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..7db3142 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):
+ 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:
@@ -15,6 +21,8 @@ def __init__(self, *args, **kwargs):
**({"self_closing": True} if self_closing else {}),
},
)
-
- _Tag.__name__ = tag.capitalize() if tag.islower() else tag
+ # @NOTE __qualname__ for serialization
+ 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 15ed806..8b41ec0 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")
@@ -16,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")
@@ -23,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_element.py b/tests/test_element.py
index 1b5e007..382cd43 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,453 @@ def test_html_escaping_normal_content(self):
rendered = str(element)
self.assertEqual(rendered, f"
", 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, "
")
+
+ # Pretty
+ pretty = fragment.render(pretty=True)
+ self.assertIn("\n", pretty)
+
+
+if __name__ == "__main__":
+ unittest.main()
\ No newline at end of file
diff --git a/tests/test_html.py b/tests/test_html.py
index 695e39b..c7046dc 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -1,17 +1,19 @@
import unittest
+from ydnatl.core.element import HTMLElement
from ydnatl.tags.html import (
HTML,
Head,
Body,
Title,
Meta,
+ Base,
Link,
Script,
Style,
+ Noscript,
IFrame,
)
-from ydnatl.core.element import HTMLElement
class TestHTMLTags(unittest.TestCase):
@@ -68,12 +70,47 @@ 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_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_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..57e46fd 100644
--- a/tests/test_media.py
+++ b/tests/test_media.py
@@ -1,16 +1,22 @@
import unittest
+from ydnatl.core.element import HTMLElement
from ydnatl.tags.media import (
Image,
Video,
Audio,
Source,
+ Track,
Picture,
Figure,
Figcaption,
Canvas,
+ Embed,
+ Object,
+ Param,
+ Map,
+ Area,
)
-from ydnatl.core.element import HTMLElement
class TestMediaTags(unittest.TestCase):
@@ -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("