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}" + # 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"\n" + else: + result = f"{tag_start}>\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}" 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"

{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)) + + + 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, '

Hello

World

') + + # Test pretty + pretty = element.render(pretty=True) + self.assertIn("\n", pretty) + self.assertIn(" ", pretty) # Should have indentation + + def test_render_pretty_deeply_nested(self): + """Test pretty printing with deeply nested structure.""" + root = HTMLElement(tag="div") + level1 = HTMLElement(tag="section") + level2 = HTMLElement(tag="article") + level2.append(HTMLElement("Content", tag="p")) + level1.append(level2) + root.append(level1) + + pretty = root.render(pretty=True) + lines = pretty.strip().split("\n") + + # Should have multiple indentation levels + self.assertTrue(any(line.startswith(" ") for line in lines)) + self.assertTrue(any(line.startswith(" ") for line in lines)) + + def test_render_pretty_self_closing(self): + """Test pretty printing with self-closing tags.""" + element = HTMLElement(tag="br", self_closing=True) + pretty = element.render(pretty=True) + self.assertEqual(pretty, "
\n") + + def test_add_style_single(self): + """Test adding a single CSS style.""" + element = HTMLElement(tag="div") + element.add_style("color", "red") + + self.assertEqual(element.get_attribute("style"), "color: red") + rendered = str(element) + self.assertIn('style="color: red"', rendered) + + def test_add_styles_multiple(self): + """Test adding multiple CSS styles at once.""" + element = HTMLElement(tag="div") + element.add_styles({ + "color": "blue", + "font-size": "14px", + "margin": "10px" + }) + + style = element.get_attribute("style") + self.assertIn("color: blue", style) + self.assertIn("font-size: 14px", style) + self.assertIn("margin: 10px", style) + + def test_add_style_updates_existing(self): + """Test that adding styles updates existing style attribute.""" + element = HTMLElement(tag="div", style="color: red") + element.add_style("font-size", "16px") + + style = element.get_attribute("style") + self.assertIn("color: red", style) + self.assertIn("font-size: 16px", style) + + def test_get_style(self): + """Test getting individual CSS style values.""" + element = HTMLElement(tag="div") + element.add_styles({"color": "green", "padding": "5px"}) + + self.assertEqual(element.get_style("color"), "green") + self.assertEqual(element.get_style("padding"), "5px") + self.assertIsNone(element.get_style("margin")) + + def test_remove_style(self): + """Test removing CSS styles.""" + element = HTMLElement(tag="div") + element.add_styles({ + "color": "red", + "font-size": "14px", + "margin": "10px" + }) + + element.remove_style("margin") + + style = element.get_attribute("style") + self.assertNotIn("margin", style) + self.assertIn("color: red", style) + self.assertIn("font-size: 14px", style) + + def test_remove_style_last_one(self): + """Test that removing the last style removes the style attribute.""" + element = HTMLElement(tag="div") + element.add_style("color", "red") + + self.assertTrue(element.has_attribute("style")) + + element.remove_style("color") + + self.assertFalse(element.has_attribute("style")) + + def test_parse_styles(self): + """Test the _parse_styles static method.""" + styles_dict = HTMLElement._parse_styles("color: red; font-size: 14px; margin: 10px") + + self.assertEqual(styles_dict["color"], "red") + self.assertEqual(styles_dict["font-size"], "14px") + self.assertEqual(styles_dict["margin"], "10px") + + def test_parse_styles_empty(self): + """Test parsing empty style string.""" + styles_dict = HTMLElement._parse_styles("") + self.assertEqual(styles_dict, {}) + + def test_format_styles(self): + """Test the _format_styles static method.""" + styles_dict = {"color": "blue", "font-size": "16px"} + formatted = HTMLElement._format_styles(styles_dict) + + self.assertIn("color: blue", formatted) + self.assertIn("font-size: 16px", formatted) + + def test_context_manager(self): + """Test using HTMLElement as a context manager.""" + with HTMLElement(tag="div", id="test") as element: + element.append(HTMLElement("Child", tag="span")) + self.assertEqual(element.count_children(), 1) + + # Should render correctly after context exit + self.assertEqual(str(element), '
Child
') + + def test_context_manager_nested(self): + """Test nested context managers.""" + with HTMLElement(tag="div") as outer: + with HTMLElement(tag="section") as inner: + inner.append(HTMLElement("Text", tag="p")) + outer.append(inner) + + self.assertEqual(outer.count_children(), 1) + self.assertEqual(outer.children[0].count_children(), 1) + + def test_method_chaining_append(self): + """Test that append returns self for chaining.""" + element = HTMLElement(tag="div") + result = element.append(HTMLElement(tag="span")) + + self.assertIs(result, element) + + # Test actual chaining + element = (HTMLElement(tag="div") + .append(HTMLElement(tag="h1")) + .append(HTMLElement(tag="p"))) + + self.assertEqual(element.count_children(), 2) + + def test_method_chaining_prepend(self): + """Test that prepend returns self for chaining.""" + element = HTMLElement(tag="div") + result = element.prepend(HTMLElement(tag="span")) + + self.assertIs(result, element) + + def test_method_chaining_add_attribute(self): + """Test that add_attribute returns self for chaining.""" + element = HTMLElement(tag="div") + result = element.add_attribute("id", "test") + + self.assertIs(result, element) + + # Test actual chaining + element = (HTMLElement(tag="div") + .add_attribute("id", "main") + .add_attribute("class", "container")) + + self.assertEqual(element.get_attribute("id"), "main") + self.assertEqual(element.get_attribute("class"), "container") + + def test_method_chaining_add_attributes(self): + """Test that add_attributes returns self for chaining.""" + element = HTMLElement(tag="div") + result = element.add_attributes([("id", "test")]) + + self.assertIs(result, element) + + def test_method_chaining_remove_attribute(self): + """Test that remove_attribute returns self for chaining.""" + element = HTMLElement(tag="div", id="test") + result = element.remove_attribute("id") + + self.assertIs(result, element) + + def test_method_chaining_add_style(self): + """Test that add_style returns self for chaining.""" + element = HTMLElement(tag="div") + result = element.add_style("color", "red") + + self.assertIs(result, element) + + # Test actual chaining + element = (HTMLElement(tag="div") + .add_style("color", "blue") + .add_style("font-size", "14px")) + + self.assertEqual(element.get_style("color"), "blue") + self.assertEqual(element.get_style("font-size"), "14px") + + def test_method_chaining_add_styles(self): + """Test that add_styles returns self for chaining.""" + element = HTMLElement(tag="div") + result = element.add_styles({"color": "red"}) + + self.assertIs(result, element) + + def test_method_chaining_remove_style(self): + """Test that remove_style returns self for chaining.""" + element = HTMLElement(tag="div") + element.add_style("color", "red") + result = element.remove_style("color") + + self.assertIs(result, element) + + def test_method_chaining_clear(self): + """Test that clear returns self for chaining.""" + element = HTMLElement(tag="div") + element.append(HTMLElement(tag="span")) + result = element.clear() + + self.assertIs(result, element) + + def test_method_chaining_remove_all(self): + """Test that remove_all returns self for chaining.""" + element = HTMLElement(tag="div") + element.append(HTMLElement(tag="span")) + result = element.remove_all(lambda x: x.tag == "span") + + self.assertIs(result, element) + + def test_method_chaining_complex(self): + """Test complex method chaining scenario.""" + element = (HTMLElement(tag="div") + .add_attribute("id", "container") + .add_attribute("class", "wrapper") + .add_style("background", "#f0f0f0") + .add_styles({"padding": "20px", "margin": "10px"}) + .append(HTMLElement("Title", tag="h1")) + .append(HTMLElement("Content", tag="p"))) + + self.assertEqual(element.get_attribute("id"), "container") + self.assertEqual(element.get_attribute("class"), "wrapper") + self.assertEqual(element.get_style("background"), "#f0f0f0") + self.assertEqual(element.get_style("padding"), "20px") + self.assertEqual(element.count_children(), 2) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_form.py b/tests/test_form.py index 747874b..f5b92dc 100644 --- a/tests/test_form.py +++ b/tests/test_form.py @@ -1,17 +1,21 @@ import unittest +from ydnatl.core.element import HTMLElement from ydnatl.tags.form import ( Textarea, Select, Option, Button, Fieldset, + Legend, Form, Input, Label, Optgroup, + Output, + Progress, + Meter, ) -from ydnatl.core.element import HTMLElement class TestFormTags(unittest.TestCase): @@ -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), "Personal Information") + + def test_fieldset_with_legend(self): + """Test fieldset element with legend and fields.""" + fieldset = Fieldset( + Legend("Contact Details"), + Input(type="email", name="email"), + ) + expected = '
Contact Details
' + 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_fragment.py b/tests/test_fragment.py new file mode 100644 index 0000000..35f1461 --- /dev/null +++ b/tests/test_fragment.py @@ -0,0 +1,284 @@ +import unittest + +from ydnatl.core.fragment import Fragment +from ydnatl.core.element import HTMLElement +from ydnatl.tags.text import H1, Paragraph, Span +from ydnatl.tags.layout import Div, Section + + +class TestFragment(unittest.TestCase): + + def test_fragment_basic(self): + """Test basic Fragment rendering without wrapper tag.""" + fragment = Fragment( + H1("Title"), + Paragraph("Content") + ) + + rendered = str(fragment) + self.assertEqual(rendered, "

Title

Content

") + self.assertNotIn("", rendered) + self.assertNotIn("", rendered) + + def test_fragment_empty(self): + """Test empty Fragment renders nothing.""" + fragment = Fragment() + rendered = str(fragment) + self.assertEqual(rendered, "") + + def test_fragment_single_child(self): + """Test Fragment with single child.""" + fragment = Fragment( + Paragraph("Single child") + ) + + rendered = str(fragment) + self.assertEqual(rendered, "

Single child

") + + def test_fragment_multiple_children(self): + """Test Fragment with multiple children.""" + fragment = Fragment( + H1("Header"), + Paragraph("Paragraph 1"), + Paragraph("Paragraph 2"), + Span("Inline text") + ) + + rendered = str(fragment) + expected = "

Header

Paragraph 1

Paragraph 2

Inline text" + self.assertEqual(rendered, expected) + + def test_fragment_nested_elements(self): + """Test Fragment with nested HTML elements.""" + fragment = Fragment( + Div( + H1("Title"), + Paragraph("Content") + ), + Section( + Paragraph("More content") + ) + ) + + rendered = str(fragment) + self.assertIn("
", rendered) + self.assertIn("

Title

", 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, "

Item 1

Item 2

Item 3

") + + def test_fragment_with_attributes(self): + """Test that Fragment ignores attributes (doesn't render them).""" + fragment = Fragment( + H1("Title"), + id="ignored", + class_name="also-ignored" + ) + + rendered = str(fragment) + self.assertNotIn("id", rendered) + self.assertNotIn("class", rendered) + self.assertEqual(rendered, "

Title

") + + def test_fragment_method_chaining(self): + """Test method chaining with Fragment.""" + fragment = (Fragment() + .append(H1("Title")) + .append(Paragraph("Content 1")) + .append(Paragraph("Content 2"))) + + self.assertEqual(fragment.count_children(), 3) + rendered = str(fragment) + self.assertIn("

Title

", rendered) + self.assertIn("

Content 1

", 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, "

Title

Content

") + + def test_fragment_clone(self): + """Test cloning a Fragment.""" + original = Fragment( + H1("Title"), + Paragraph("Content") + ) + + cloned = original.clone() + + self.assertEqual(str(original), str(cloned)) + self.assertIsNot(original, cloned) + self.assertIsNot(original.children[0], cloned.children[0]) + + def test_fragment_filter(self): + """Test filtering Fragment children.""" + fragment = Fragment( + H1("Title"), + Paragraph("Para 1"), + Span("Span"), + Paragraph("Para 2") + ) + + paragraphs = list(fragment.filter(lambda x: x.tag == "p")) + self.assertEqual(len(paragraphs), 2) + + def test_fragment_find_by_attribute(self): + """Test finding elements by attribute in Fragment.""" + fragment = Fragment( + H1("Title", id="header"), + Paragraph("Content", id="main"), + Span("Text") + ) + + found = fragment.find_by_attribute("id", "main") + self.assertIsNotNone(found) + self.assertEqual(found.tag, "p") + + def test_fragment_compact_vs_pretty(self): + """Test compact vs pretty rendering of Fragment.""" + fragment = Fragment( + Div( + H1("Title"), + Paragraph("Content") + ) + ) + + # Compact + compact = fragment.render(pretty=False) + self.assertNotIn("\n", compact) + self.assertEqual(compact, "

Title

Content

") + + # 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), "
Sidebar content
") + + 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), "Dialog content") + + 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("' ) + def test_caption(self): + """Test the creation of a caption element.""" + caption = Caption("Table Title") + self.assertEqual(caption.tag, "caption") + self.assertEqual(str(caption), "Table Title") + + def test_table_with_caption(self): + """Test table element with caption.""" + table = Table( + Caption("Monthly Sales"), + TableRow(TableHeaderCell("Month"), TableHeaderCell("Sales")), + ) + self.assertIn("Monthly Sales", str(table)) + + def test_col(self): + """Test the creation of a col element.""" + col = Col(span="2", style="background-color: yellow") + self.assertEqual(col.tag, "col") + self.assertTrue(col.self_closing) + self.assertIn('span="2"', str(col)) + + def test_colgroup(self): + """Test the creation of a colgroup element.""" + colgroup = Colgroup(Col(span="2"), Col(style="background-color: yellow")) + self.assertEqual(colgroup.tag, "colgroup") + self.assertIn("", str(table)) + self.assertIn("This is a paragraph.

' ) + 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))