From 969d11985e72545caaefc8910560511cc2ab3a68 Mon Sep 17 00:00:00 2001 From: Michel Lutz Date: Sat, 19 Sep 2026 00:02:39 -0300 Subject: [PATCH 1/9] Add: the Winged-Python 1.0 library, replacing the 0.1.0 API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clean break. 0.1.0 escaped nothing, shipped 49 tags against Winged-Swift's 93, kept winged/HTML/__init__.py entirely commented out so there was no public API, mutated sys.path on import, and defined Attribute twice in one file. The new library, at parity with Winged-Swift 2.0.0: - core.escape — the module 0.1.0 did not have at all. Text and attribute values are escaped by default; RawHtml is the only opt-out. - core.element — one buffered write_into primitive instead of a string per node, per-instance state, void elements that refuse children rather than dropping them, and
//
-
-Methods: 
-All methods in the Tag superclass are available to instances of the Textarea class.
-
-Note: The 'textarea' tag in HTML defines a multiline input field, i.e., a control that allows the user to input text 
-over multiple rows."""
-
-
-class Textarea(Tag):
-    _tag = "textarea"  # Specifies the tag name.
-    _container = True  # Specifies that Textarea can container other elements.
-    _form_element = True  # Specifies that Textarea is a form element.
diff --git a/winged/HTML/th.py b/winged/HTML/th.py
deleted file mode 100644
index 50c07e4..0000000
--- a/winged/HTML/th.py
+++ /dev/null
@@ -1,34 +0,0 @@
-from winged.core.tag import Tag
-
-"""
-The TH class is a specific implementation of the HTML 'th' tag in the Winged-Python library.
-It inherits from the Tag class and provides the necessary methods to generate 'th' HTML elements.
-
-The 'th' tag defines a header cell in a table.
-
-# Features:
-- It is a container element and can contain other HTML elements.
-- It is not considered a form element.
-
-## Example Usage:
-
-```python
-table_header = TH()
-table_header.add(String("Column Header"))   # Add header text as needed
-```
-
-This would generate: Column Header
-
-Methods:
-- All methods available in the Tag class can be called by TH instances.
-
-Note:
-The 'th' tag is used to create a table header cell, which is used to label the column(s) that it spans.
-By default, browsers render the content of a 'th' cell in bold and centered.
-"""
-
-
-class Th(Tag):
-    _tag = "th"  # Specifies the name of the tag
-    _container = True  # Specifies that TH can contain other HTML elements
-    _form_element = False  # Specifies that TH is not a form element
diff --git a/winged/HTML/thead.py b/winged/HTML/thead.py
deleted file mode 100644
index 3c900ce..0000000
--- a/winged/HTML/thead.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from winged.core.tag import Tag
-
-"""
-The THead class is a specific implementation of the HTML 'thead' tag in the Winged-Python library.
-It inherits from the Tag class and provides the necessary methods to generate 'thead' HTML elements.
-
-The 'thead' tag is used to group the header content in an HTML table.
-
-# Features:
-- THead is a container and can encapsulate other HTML elements.
-- It is not a form element.
-
-## Example Usage:
-
-```python
-thead = THead()    # Create an instance of 'thead' 
-thead.add(Tag()) # Add rows (tr elements) to the thead tag
-```
-
-This would generate: 
-
-Methods:
-- All methods available in the Tag class can be called by THead instances.
-
-Note:
-The 'thead' HTML tag is used for grouping the header content in a table. 
-The 'thead' element is useful when dealing with large tables. 
-If the table is taller than the viewport, the page will scroll, but the 'thead' section will remain visible.
-"""
-
-
-class THead(Tag):
-    _tag = "thead"  # Specifies the name of the tag
-    _container = True  # Specifies that THead can contain other HTML elements
-    _form_element = False  # Specifies that THead is not a form element
diff --git a/winged/HTML/title.py b/winged/HTML/title.py
deleted file mode 100644
index c5a74ab..0000000
--- a/winged/HTML/title.py
+++ /dev/null
@@ -1,53 +0,0 @@
-from winged.core.tag import Tag
-
-"""This module defines a class called 'Title' that represents a 'title' HTML-like container element for specifying 
-the document's title.
-
-The 'Title' class is a specific implementation of the 'Tag' class and is used to create and manipulate 'title' 
-container elements within an HTML-like document. It inherits attributes and methods from the 'Tag' class and 
-specializes in handling 'title' elements, which specify the title of the document.
-
-# Usage Example:
-
-```python
-from winged.core.tag import Tag
-
-# Create a 'Title' instance
-title_element = Title()
-
-# Set the text content of the 'Title' element
-title_element.add("Page Title")
-
-# Get the string representation of the 'Title' element
-title_string = title_element.get_string()
-
-# Generate and print the 'Title' element
-title_element.generate()
-```
-
-# Class Attributes:
-
-- _tag (str): The name of the tag, which is set to "title" for 'Title' elements. - _container (bool): Indicates that 
-'Title' elements are container elements. - _form_element (bool): Indicates that 'Title' elements are not form 
-elements, as they represent the title of the document.
-
-# Methods:
-
-- __init__(): Constructor method to initialize a 'Title' instance.
-- add(element: ElementAbstract): Set the text content of the 'Title' element.
-- get_tag() -> str: Get the name of the tag ("title").
-- is_container() -> bool: Check if the 'Title' element is a container.
-- is_form_element() -> bool: Check if the 'Title' element is a form element.
-- get_string() -> str: Get the string representation of the 'Title' element, including its nested elements and content.
-- generate(): Print the string representation of the 'Title' element to the console.
-
-# Note:
-
-- The 'Title' class is a specialization of the 'Tag' class for representing 'title' container elements, which specify 
-the title of the document."""
-
-
-class Title(Tag):
-    _tag = "title"
-    _container = True
-    _form_element = False
diff --git a/winged/HTML/tr.py b/winged/HTML/tr.py
deleted file mode 100644
index f700113..0000000
--- a/winged/HTML/tr.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from winged.core.tag import Tag
-
-"""
-The TR class is a specific implementation of the HTML 'tr' tag in the Winged-Python library.
-It inherits from the Tag class and provides the necessary methods to generate 'tr' HTML elements.
-
-The 'tr' tag defines a row of cells in a table.
-
-# Features:
-- This is a container and can encapsulate other HTML elements.
-- It is not considered a form element.
-
-## Example Usage:
-
-```python
-row = Tr()
-```
-
-This would generate: 
-
-Methods:
-- All methods available in the Tag class can be called by TR instances.
-
-Note:
-The 'tr' tag in HTML is used to group together th or td values into a single row of table heading or data values.
-The 'tr' tag must be nested inside a 'table' tag or must be be placed between 'thead', 'tbody', or 'tfoot' tags.
-"""
-
-
-class Tr(Tag):
-    _tag = "tr"  # Specifies the name of the tag
-    _container = True  # Specifies that TR can contain other HTML elements
-    _form_element = False  # Specifies that TR is not a form element
diff --git a/winged/HTML/u.py b/winged/HTML/u.py
deleted file mode 100644
index 891b771..0000000
--- a/winged/HTML/u.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from winged.core.tag import Tag
-
-"""
-The U class is a specific implementation of the HTML 'u' tag in the Winged-Python library.
-It inherits from the Tag class and provides the necessary methods to generate 'u' HTML elements.
-
-The 'u' tag represents an unarticulated annotation in the text, typically styled as underlined text.
-
-# Features:
-- It is a container element, meaning it can hold other HTML elements.
-- It is not considered a form element.
-
-## Example Usage:
-
-```python
-underlined_text = U()
-underlined_text.add(String('This is underlined text'))  # Adding a string to the U tag
-```
-
-This would generate: This is underlined text
-
-Methods:
-- All methods available in the Tag class can be called by U instances.
-
-Note: The 'u' tag in HTML is used to define text that should be stylistically different from normal text, 
-such as misspelled words or proper nouns in Chinese."""
-
-
-class U(Tag):
-    _tag = "u"  # Specifies the name of the tag.
-    _container = True  # Specifies that U tag can contain other HTML elements.
-    _form_element = False  # Specifies that U is not a form element.
diff --git a/winged/HTML/ul.py b/winged/HTML/ul.py
deleted file mode 100644
index ba4cd71..0000000
--- a/winged/HTML/ul.py
+++ /dev/null
@@ -1,34 +0,0 @@
-from winged.core.tag import Tag
-
-"""
-The UL class is a specific implementation of the HTML 'ul' tag in the Winged-Python library.
-It inherits from the Tag class and provides necessary methods for generating 'ul' tag-based HTML elements.
-
-The 'ul' tag is used for grouping a collection of items which do not have a numerical ordering, often displayed with 
-bullet points.
-
-# Features:
-- It can act as a container and encapsulate other HTML elements.
-- It is not considered a form element.
-
-## Example Usage:
-
-```python
-ul = Ul()   # Create an instance of UL
-ul.add(Tag()) # Add other HTML elements as needed
-```
-
-This results in the following HTML: 
- -Methods: -The UL class has access to all methods implemented in the Tag base class. - -Note: -The 'ul' tag contains 'li' tags, each of which represents one item in the list. -""" - - -class Ul(Tag): - _tag = "ul" # Specifies the name of the tag - _container = True # Specifies that UL can encapsulate other HTML elements - _form_element = False # Specifies that UL is not a form element diff --git a/winged/__init__.py b/winged/__init__.py deleted file mode 100644 index 909929e..0000000 --- a/winged/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -import sys -sys.path.insert(0, '') \ No newline at end of file diff --git a/winged/core/attribute.py b/winged/core/attribute.py deleted file mode 100644 index 1eebac5..0000000 --- a/winged/core/attribute.py +++ /dev/null @@ -1,124 +0,0 @@ -from winged.core.attribute_type import AttributeType - -""" -This module defines a Python class called 'Attribute' used for managing attributes. - -The 'Attribute' class allows you to create and manage a collection of attributes. It is particularly useful for representing and manipulating attribute data within your code. - -Usage Example: -```python -from winged.core.attribute_type import AttributeType - -# Create an 'Attribute' instance with attributes -attribute1 = ("name", "John") -attribute2 = ("age", 30) -attr = Attribute(attribute1, attribute2) - -# Add a new single attribute -attr.add_attribute(("city", "New York")) - -# Get all attributes as a list -all_attributes = attr.get_attributes() - -# Get attributes as a formatted string -formatted_string = attr.get_string() - -# Print all attributes -attr.generate() - -Class Attributes: - -- _attributes (list): A list that stores the attributes added to the 'Attribute' instance. -Methods: - -- __init__(*attributes: AttributeType): Constructor method to initialize the 'Attribute' instance with the provided attributes. -- add_attribute(attribute: AttributeType): Add a new single attribute to the 'Attribute' instance. -- get_attributes() -> list: Get all attributes as a list. -- get_string() -> str: Get a formatted string representation of all attributes. -- generate(): Print all attributes to the console. - -# Note: - -- The 'Attribute' class uses the 'AttributeType' type from the 'winged.core.attribute_type' module for attribute typing. -- Attributes can be of various types, including strings, integers, or tuples of (key, value) pairs. - -Make sure to import the necessary modules and use the 'Attribute' class according to your requirements. -""" - - -class Attribute: - _attributes: [AttributeType] - - def __init__(self, *attributes: AttributeType): - self._attributes = [] - for att in attributes: - self._attributes.append(att) - - # This method add a new single attribute - def add_attribute(self, attribute: AttributeType): - self._attributes.append(attribute) - - # This function return all attribures - - def get_attributes(self): - return self._attributes - - # This method return a `String` with all attributes - def get_string(self): - parts = [] - - for attribute in self._attributes: - # Verificar se o attribute é um par - if isinstance(attribute, tuple) and len(attribute) == 2: - key, value = attribute - if value is not None: - parts.append(f"{key}=\"{value}\"") - else: - parts.append(f"{key}") - else: - parts.append(f"{attribute}") - - return " ".join(parts) - - # This method print all attributes - def generate(self): - print(self.get_string()) - - -class Attribute: - _attributes: [AttributeType] - - def __init__(self, *attributes: AttributeType): - self._attributes = [] - for att in attributes: - self._attributes.append(att) - - # This method add a new single attribute - def add_attribute(self, attribute: AttributeType): - self._attributes.append(attribute) - - # This function return all attribures - - def get_attributes(self): - return self._attributes - - # This method return a `String` with all attributes - def get_string(self): - parts = [] - - for attribute in self._attributes: - # Verificar se o attribute é um par - if isinstance(attribute, tuple) and len(attribute) == 2: - key, value = attribute - if value is not None: - parts.append(f"{key}=\"{value}\"") - else: - parts.append(f"{key}") - else: - parts.append(f"{attribute}") - - return " ".join(parts) - - # This method print all attributes - def generate(self): - print(self.get_string()) diff --git a/winged/core/attribute_type.py b/winged/core/attribute_type.py deleted file mode 100644 index ab825c3..0000000 --- a/winged/core/attribute_type.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Tuple, Optional - -""" -This file contains the definition of the 'AttributeType' data type. - -An 'AttributeType' is a tuple that represents an attribute with two elements: -1. A string describing the name of the attribute. -2. An optional string that can contain additional description of the attribute. - -Usage Example: -attribute: AttributeType = ("name", "This is an optional description.") - -The first element of the tuple ('str') is mandatory, while the second element ('Optional[str]') is optional and can -be null (None) if there is no description available. - -This is useful for documenting and typing attributes within data structures in a more informative and readable manner. - -Important: -Ensure that you import 'AttributeType' when using it in your modules to avoid type errors. - -Example Import: -from typing import Tuple, Optional - -# AttributeType is a Tuple (str, str?) -AttributeType = Tuple[str, Optional[str]] -""" -AttributeType = Tuple[str, Optional[str]] \ No newline at end of file diff --git a/winged/core/element_abstract.py b/winged/core/element_abstract.py deleted file mode 100644 index 6eee5c2..0000000 --- a/winged/core/element_abstract.py +++ /dev/null @@ -1,50 +0,0 @@ -from abc import abstractmethod - -""" -This module defines an abstract base class called 'ElementAbstract' for elements in an abstract data structure. - -The 'ElementAbstract' class is intended to serve as a base class for other concrete classes representing elements in an abstract data structure. It defines two abstract methods that any subclass must implement: 'get_string' and 'generate'. - -Usage Example: -```python -from abc import abstractmethod - -class ConcreteElement(ElementAbstract): - def __init__(self, data): - self.data = data - - def get_string(self): - return str(self.data) - - def generate(self): - print(f"Generated: {self.data}") - -# Create an instance of ConcreteElement -element = ConcreteElement("example_data") - -# Call the abstract methods -string_representation = element.get_string() -element.generate() -``` -# Class Methods: - -- get_string(self): An abstract method that should return a string representation of the element. Subclasses must implement this method. -- generate(self): An abstract method that should perform some generation or processing related to the element. Subclasses must implement this method. - -# Note: - -- This class is designed to be subclassed, and it enforces the implementation of the two abstract methods. -- The 'ElementAbstract' class is part of the 'abc' (Abstract Base Classes) module in Python and is used for defining abstract base classes. - -When creating concrete classes that inherit from 'ElementAbstract', make sure to implement the required methods as specified by this base class. -""" - - -class ElementAbstract: - @abstractmethod - def get_string(self): - pass - - @abstractmethod - def generate(self): - pass diff --git a/winged/core/generic_element.py b/winged/core/generic_element.py deleted file mode 100644 index 5acdedd..0000000 --- a/winged/core/generic_element.py +++ /dev/null @@ -1,66 +0,0 @@ -from winged.core.element_abstract import ElementAbstract - -""" -This module defines a class called 'GenericElement' that represents a generic element in a data structure. - -The 'GenericElement' class is intended to be used as a part of a more complex data structure. It extends the 'ElementAbstract' class and provides methods for adding elements and generating a string representation of its content. - -Usage Example: -```python -from winged.core.element_abstract import ElementAbstract - -# Create instances of GenericElement -element1 = GenericElement() -element2 = GenericElement() - -# Add elements to the GenericElement instances -element1.add(element2) -element1.add(ElementAbstract()) - -# Get the string representation of the elements -string_representation = element1.get_string() - -# Generate and print the string representation -element1.generate() -``` - -# Class Attributes: - -- _elements (list): A list that stores the elements added to the 'GenericElement' instance. - -# Methods: - -- __init__(self): Constructor method to initialize the 'GenericElement' instance with an empty list of elements. -- add(element: ElementAbstract): Add a new element to the 'GenericElement' instance. -- get_string(self) -> str: Get a concatenated string representation of all elements within the 'GenericElement' instance. -- generate(self): Print the string representation of the 'GenericElement' instance to the console. - -# Note: - -- The 'GenericElement' class extends 'ElementAbstract' and inherits the abstract methods 'get_string' and 'generate'. It provides concrete implementations for these methods. -- You can create more specialized subclasses of 'GenericElement' to represent specific elements within your data structure. -""" - - -class GenericElement(ElementAbstract): - _elements: [ElementAbstract] - - def __init__(self): - self._elements = [] - - # This method add a new element - def add(self, element: ElementAbstract): - self._elements.append(element) - - # This method return tag and all elements - def get_string(self): - string = "" - - for element in self._elements: - string += element.get_string() - - return string - - # This method print tag and all elements - def generate(self): - print(self.get_string()) diff --git a/winged/core/tag.py b/winged/core/tag.py deleted file mode 100644 index fe5fe44..0000000 --- a/winged/core/tag.py +++ /dev/null @@ -1,121 +0,0 @@ -from winged.core.attribute_type import AttributeType -from winged.core.attribute import Attribute -from winged.core.element_abstract import ElementAbstract - -""" -This module defines a class called 'Tag' that represents an HTML-like tag with attributes and elements. - -The 'Tag' class extends the 'ElementAbstract' class and is used to create and manipulate HTML-like tags within a document. It allows you to define attributes, add elements, and generate the string representation of the tag. - -Usage Example: -```python -from winged.core.attribute_type import AttributeType -from winged.core.attribute import Attribute -from winged.core.element_abstract import ElementAbstract - -# Create a 'Tag' instance with attributes and elements -attributes = (AttributeType("class", "container"), AttributeType("id", "example")) -tag = Tag(*attributes) - -# Add elements to the 'Tag' instance -tag.add(ElementAbstract()) -tag.add(Tag(AttributeType("src", "image.jpg"))) - -# Get the string representation of the tag -string_representation = tag.get_string() - -# Generate and print the tag -tag.generate() -``` -# Class Attributes: - -- _tag (str): The name of the tag (e.g., 'div', 'a', 'p'). -- _attributes (Attribute): An instance of the 'Attribute' class for managing attributes of the tag. -- _container (bool): Indicates whether the tag is a container for other elements. -- _form_element (bool): Indicates whether the tag is a form element. -- _elements (list): A list that stores elements added to the 'Tag' instance. - -# Methods: - -- __init__(*attributes: AttributeType): Constructor method to initialize the 'Tag' instance with attributes. -- add_attributes(*attributes: AttributeType): Add new attributes to the 'Tag' instance. -- add(element: ElementAbstract): Add a new element to the 'Tag' instance. -- _get_open_tag(): Get the opening tag string with attributes. -- _get_close_tag(): Get the closing tag string. -- get_tag() -> str: Get the name of the tag. -- get_attributes() -> Attribute: Get the attributes of the tag. -- is_container() -> bool: Check if the tag is a container. -- is_form_element() -> bool: Check if the tag is a form element. -- get_string() -> str: Get the complete string representation of the tag, including attributes and nested elements. -- generate(): Print the string representation of the 'Tag' instance to the console. - -# Note: - -- The 'Tag' class is designed to represent HTML-like tags and elements within a document. -- It supports attributes, nesting of elements, and differentiating between container and form elements. - -Customize and expand this documentation as necessary to align with the specific requirements and structure of your project. -""" - - -class Tag(ElementAbstract): - _tag: str = "" - _attributes: Attribute - _container: bool = False - _form_element: bool = False - _elements: [ElementAbstract] - - def __init__(self, *attributes: AttributeType): - self._attributes = Attribute() - self._elements = [] - - for att in attributes: - self._attributes.add_attribute(att) - - # This method add a new n attributes - def add_attributes(self, *attributes: AttributeType): - for att in attributes: - self._attributes.add_attribute(att) - - # This method add a new element - def add(self, element: ElementAbstract): - self._elements.append(element) - - # This method return open tag and all attributes - def _get_open_tag(self): - attr = self._attributes.get_string() - - if len(attr) > 0: - return f"<{self._tag} {attr}>" - else: - return f"<{self._tag}>" - - # This method return close tag - def _get_close_tag(self): - return f"" - - def get_tag(self): - return self._tag - - def get_attributes(self): - return self._attributes - - def is_container(self): - return self._container - - def is_form_element(self): - return self._form_element - - # This method return tag and all elements - def get_string(self): - string = self._get_open_tag() - - if self._container: - for element in self._elements: - string += element.get_string() - string += self._get_close_tag() - return string - - # This method print tag and all elements - def generate(self): - print(self.get_string()) From ced6e00792bdc0661a535d2ad8168a2d1d6b18ba Mon Sep 17 00:00:00 2001 From: Michel Lutz Date: Sat, 19 Sep 2026 00:02:53 -0300 Subject: [PATCH 2/9] Add: the test suite, and golden-file parity with Winged-Swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 304 tests, 92% coverage, replacing 8 files that covered 4 of 50 tag modules. tests/test_golden.py reproduces all four of Winged-Swift's frozen fixtures byte for byte, which is what turns "parity" from a claim into a test. WINGED_UPDATE_FIXTURES=1 rewrites a fixture and fails the run, so a stale fixture can never pass silently. tests/test_tag_catalog.py constructs and renders every element from the table, so a tag cannot be added without being tested. The old tests were unittest.TestCase merely run by pytest, with the same MockElement copy-pasted into four files and stdout captured by hand — not exception-safe, so one failing assertion left stdout redirected for the rest of the session. All of that is gone: render() returns the string. Co-Authored-By: Claude Opus 5 (1M context) --- tests/__init__.py | 0 tests/attribute_test.py | 52 -------- tests/attribute_type_test.py | 17 --- tests/conftest.py | 23 ++++ tests/div_test.py | 52 -------- tests/fixtures/feed.xml | 18 +++ tests/fixtures/marketing-compact.html | 2 + tests/fixtures/marketing-pretty.html | 84 ++++++++++++ tests/fixtures/sitemap.xml | 14 ++ tests/generic_element_test.py | 41 ------ tests/marketing.py | 112 ++++++++++++++++ tests/p_test.py | 52 -------- tests/span_test.py | 52 -------- tests/table_test.py | 34 ----- tests/tag_test.py | 22 ---- tests/test_accessibility.py | 85 ++++++++++++ tests/test_attribute.py | 31 +++++ tests/test_cli.py | 183 ++++++++++++++++++++++++++ tests/test_document.py | 55 ++++++++ tests/test_element.py | 70 ++++++++++ tests/test_escape.py | 42 ++++++ tests/test_golden.py | 79 +++++++++++ tests/test_helpers.py | 38 ++++++ tests/test_nodes.py | 41 ++++++ tests/test_package.py | 40 ++++++ tests/test_performance.py | 39 ++++++ tests/test_seo.py | 86 ++++++++++++ tests/test_sitemap_feed.py | 79 +++++++++++ tests/test_ssg.py | 62 +++++++++ tests/test_tag_catalog.py | 66 ++++++++++ 30 files changed, 1249 insertions(+), 322 deletions(-) create mode 100644 tests/__init__.py delete mode 100644 tests/attribute_test.py delete mode 100644 tests/attribute_type_test.py create mode 100644 tests/conftest.py delete mode 100644 tests/div_test.py create mode 100644 tests/fixtures/feed.xml create mode 100644 tests/fixtures/marketing-compact.html create mode 100644 tests/fixtures/marketing-pretty.html create mode 100644 tests/fixtures/sitemap.xml delete mode 100644 tests/generic_element_test.py create mode 100644 tests/marketing.py delete mode 100644 tests/p_test.py delete mode 100644 tests/span_test.py delete mode 100644 tests/table_test.py delete mode 100644 tests/tag_test.py create mode 100644 tests/test_accessibility.py create mode 100644 tests/test_attribute.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_document.py create mode 100644 tests/test_element.py create mode 100644 tests/test_escape.py create mode 100644 tests/test_golden.py create mode 100644 tests/test_helpers.py create mode 100644 tests/test_nodes.py create mode 100644 tests/test_package.py create mode 100644 tests/test_performance.py create mode 100644 tests/test_seo.py create mode 100644 tests/test_sitemap_feed.py create mode 100644 tests/test_ssg.py create mode 100644 tests/test_tag_catalog.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/attribute_test.py b/tests/attribute_test.py deleted file mode 100644 index 67f04a6..0000000 --- a/tests/attribute_test.py +++ /dev/null @@ -1,52 +0,0 @@ -import unittest -import io -import sys -from winged.core.attribute import Attribute - - -class TestAttribute(unittest.TestCase): - - def test_init(self): - # Testar a inicialização com atributos - attribute = Attribute(('key1', 'value1'), ('key2', 'value2')) - self.assertEqual(attribute.get_attributes(), [('key1', 'value1'), ('key2', 'value2')]) - - def test_addAttribute(self): - # Testar a adição de um atributo - attribute = Attribute() - attribute.add_attribute(('key', 'value')) - self.assertIn(('key', 'value'), attribute.get_attributes()) - - def test_getAttributes(self): - # Testar a obtenção dos atributos - attribute = Attribute(('key1', 'value1'), ('key2', 'value2')) - self.assertEqual(attribute.get_attributes(), [('key1', 'value1'), ('key2', 'value2')]) - - def test_getString(self): - # Testar a formatação da string dos atributos - attribute = Attribute(('key1', 'value1'), ('key2', 'value2')) - expected_string = 'key1="value1" key2="value2"' - self.assertEqual(attribute.get_string(), expected_string) - - def test_generate(self): - attr = Attribute(("id", "123"), ("name", "test")) - - # Redireciona a saída padrão para um StringIO - captured_output = io.StringIO() - sys.stdout = captured_output - - # Chama o método generate - attr.generate() - - # Redefine a saída padrão para o valor padrão - sys.stdout = sys.__stdout__ - - # Obtém o valor do StringIO - output = captured_output.getvalue().strip() - - # Verifica se a saída é o que você espera - self.assertEqual(output, 'id="123" name="test"') - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/attribute_type_test.py b/tests/attribute_type_test.py deleted file mode 100644 index 8beee0a..0000000 --- a/tests/attribute_type_test.py +++ /dev/null @@ -1,17 +0,0 @@ -# Tests for attribute_type.py -import unittest -from winged.core.attribute_type import AttributeType - - -# # AttributeType é definido como um alias para um tipo de tupla, e não como uma classe ou uma função que pode ser -# instanciada. Em Python, quando você define um tipo de tupla usando typing.Tuple, está apenas criando um alias para -# anotar tipos, não uma função ou classe construtora. -class AttributeTypeTest(unittest.TestCase): - def test_attribute_type(self): - attribute_type: AttributeType = ("id", "id01") - self.assertEqual(attribute_type[0], "id") - self.assertEqual(attribute_type[1], "id01") - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e181caa --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +"""Shared fixtures. + +0.1.0's tests defined the same ``MockElement`` helper in four files and captured output by +swapping ``sys.stdout`` for a ``StringIO`` by hand -- which is not exception-safe, so a +failing assertion between the two assignments left stdout redirected for the rest of the +session. Neither is needed now: ``render`` returns the string. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def fixture() -> object: + def read(name: str) -> str: + return (FIXTURES / name).read_text(encoding="utf-8") + + return read diff --git a/tests/div_test.py b/tests/div_test.py deleted file mode 100644 index 5405cf5..0000000 --- a/tests/div_test.py +++ /dev/null @@ -1,52 +0,0 @@ -import unittest -from abc import ABC - -from winged.HTML.div import Div -import io -import sys -from winged.core.element_abstract import ElementAbstract - - -class MockElement(ElementAbstract, ABC): - def get_string(self): - return "mock_element" - - -class DivTest(unittest.TestCase): - def test_tag_attribute(self): - div = Div() - self.assertEqual(div._tag, "div") - - def test_container_attribute(self): - div = Div() - self.assertTrue(div.is_container()) - - def test_form_element_attribute(self): - div = Div() - self.assertFalse(div.is_form_element()) - - def test_add_element(self): - div = Div() - mock_element = MockElement() - div.add(mock_element) - self.assertEqual(div.get_string(), '
mock_element
') - - def test_generate(self): - div = Div() - mock_element = MockElement() - div.add(mock_element) - - # Redireciona a saída padrão para um objeto StringIO para testar a saída gerada - captured_output = io.StringIO() - sys.stdout = captured_output - - div.generate() - - sys.stdout = sys.__stdout__ - - output = captured_output.getvalue().strip() - self.assertEqual(output, '
mock_element
') - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/fixtures/feed.xml b/tests/fixtures/feed.xml new file mode 100644 index 0000000..1ad91a4 --- /dev/null +++ b/tests/fixtures/feed.xml @@ -0,0 +1,18 @@ + + + + RideKeeper + https://ridekeeper.example + Release notes + + pt-BR + + 1.2 — tyres & chain + https://ridekeeper.example/blog/1-2 + Tyre pressure log <and> chain reminders + Tue, 11 Aug 2026 10:00:00 +0000 + https://ridekeeper.example/blog/1-2 + release + + + \ No newline at end of file diff --git a/tests/fixtures/marketing-compact.html b/tests/fixtures/marketing-compact.html new file mode 100644 index 0000000..5572b51 --- /dev/null +++ b/tests/fixtures/marketing-compact.html @@ -0,0 +1,2 @@ + +RideKeeper — track every service

Track every service

Fuel, tyres & chain — all in one place.

Download
Plans
PlanPrice
FreeR$ 0
ProR$ 9,90/mês
Is my data private?

Yes — everything syncs through your own iCloud account.

Newsletter
let page = html { }
App screenshot
The garage screen

© 2026 RideKeeper — built with Swift & WingedSwift

\ No newline at end of file diff --git a/tests/fixtures/marketing-pretty.html b/tests/fixtures/marketing-pretty.html new file mode 100644 index 0000000..757f773 --- /dev/null +++ b/tests/fixtures/marketing-pretty.html @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + RideKeeper — track every service + + + +
+ +
+
+
+

Track every service

+

Fuel, tyres & chain — all in one place.

+ Download +
+ + + + + + + + + + + + + + + + + + +
Plans
PlanPrice
FreeR$ 0
ProR$ 9,90/mês
+
+ Is my data private? +

Yes — everything syncs through your own iCloud account.

+
+
+
+ Newsletter + + + +
+
+
let page = html { }
+
+ App screenshot +
The garage screen
+
+
+
+

© 2026 RideKeeper — built with Swift & WingedSwift

+
+ + \ No newline at end of file diff --git a/tests/fixtures/sitemap.xml b/tests/fixtures/sitemap.xml new file mode 100644 index 0000000..4544395 --- /dev/null +++ b/tests/fixtures/sitemap.xml @@ -0,0 +1,14 @@ + + + + https://ridekeeper.example/ + weekly + 1.0 + + + https://ridekeeper.example/pricing?plan=pro&billing=year + 2026-08-11 + monthly + 0.7 + + \ No newline at end of file diff --git a/tests/generic_element_test.py b/tests/generic_element_test.py deleted file mode 100644 index 9110272..0000000 --- a/tests/generic_element_test.py +++ /dev/null @@ -1,41 +0,0 @@ -import unittest -import io -import sys -from abc import ABC - -from winged.core.generic_element import GenericElement -from winged.core.element_abstract import ElementAbstract - - -class MockElement(ElementAbstract, ABC): - def get_string(self): - return "mock_element" - - -class GenericElementTest(unittest.TestCase): - def test_add_and_getString(self): - element = GenericElement() - mock_element = MockElement() - - element.add(mock_element) - - self.assertEqual(element.get_string(), "mock_element") - - def test_generate(self): - element = GenericElement() - mock_element = MockElement() - element.add(mock_element) - - captured_output = io.StringIO() - sys.stdout = captured_output - - element.generate() - - sys.stdout = sys.__stdout__ - - output = captured_output.getvalue().strip() - self.assertEqual(output, "mock_element") - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/marketing.py b/tests/marketing.py new file mode 100644 index 0000000..0dea752 --- /dev/null +++ b/tests/marketing.py @@ -0,0 +1,112 @@ +"""The marketing page the golden fixtures were frozen from. + +The same tags, the same attributes, the same order as WingedSwift's +``GoldenFileTests``. Kept out of the test module so both the golden test and the +performance guard can build it. +""" + +from __future__ import annotations + +from winged import ( + H1, + A, + Body, + Button, + Caption, + Code, + Details, + Document, + Fieldset, + Figcaption, + Figure, + Footer, + Form, + Head, + Header, + Img, + Input, + Label, + Legend, + Li, + Link, + Main, + Nav, + P, + Pre, + Section, + Summary, + Table, + Tbody, + Td, + Th, + Thead, + Title, + Tr, + Ul, +) +from winged.seo import SeoBuilder + + +def marketing_page() -> Document: + seo = SeoBuilder( + title="RideKeeper", + description="Motorcycle maintenance companion", + image="https://ridekeeper.example/og.jpg", + url="https://ridekeeper.example", + keywords=["swift", "motorcycle"], + author="Michel Lutz", + twitter_site="@micheltlutz", + ) + head = Head( + seo.build(), + Title("RideKeeper — track every service"), + Link(href="/css/style.css", rel="stylesheet"), + ) + body = Body( + Header( + Nav( + A("RideKeeper", href="/", cls="logo"), + Ul( + Li(A("Home", href="/")), + Li(A("Pricing & plans", href="/pricing")), + ), + ) + ), + Main( + Section( + H1("Track every service"), + P("Fuel, tyres & chain — all in one place."), + A("Download", href="https://apps.example/app", cls="button"), + id_="hero", + ), + Table( + Caption("Plans"), + Thead(Tr(Th("Plan"), Th("Price"))), + Tbody( + Tr(Td("Free"), Td("R$ 0")), + Tr(Td("Pro"), Td("R$ 9,90/mês")), + ), + ), + Details( + Summary("Is my data private?"), + P("Yes — everything syncs through your own iCloud account."), + open=True, + ), + Form( + Fieldset( + Legend("Newsletter"), + Label("E-mail", for_="email"), + Input(type_="email", name="email", required=True), + Button("Subscribe", type_="submit"), + ), + action="/subscribe", + ), + Pre(Code("let page = html { }")), + Figure( + Img("/img/app.png", "App screenshot"), + Figcaption("The garage screen"), + ), + ), + Footer(P("© 2026 RideKeeper — built with Swift & WingedSwift")), + ) + return Document(head, body, lang="pt-BR") diff --git a/tests/p_test.py b/tests/p_test.py deleted file mode 100644 index e56af9f..0000000 --- a/tests/p_test.py +++ /dev/null @@ -1,52 +0,0 @@ -import unittest -import io -import sys -from abc import ABC - -from winged.HTML.p import P -from winged.core.element_abstract import ElementAbstract - - -class MockElement(ElementAbstract, ABC): - def get_string(self): - return "mock_element" - - -class PTest(unittest.TestCase): - def test_tag_attribute(self): - p = P() - self.assertEqual(p.get_tag(), "p") - - def test_container_attribute(self): - p = P() - self.assertTrue(p.is_container()) - - def test_form_element_attribute(self): - p = P() - self.assertFalse(p.is_form_element()) - - def test_generate(self): - p = P() - mock_element = MockElement() - p.add(mock_element) - - # Redireciona a saída padrão para um objeto StringIO para testar a saída gerada - captured_output = io.StringIO() - sys.stdout = captured_output - - p.generate() - - sys.stdout = sys.__stdout__ - - output = captured_output.getvalue().strip() - self.assertEqual(output, '

mock_element

') - - def test_add_element(self): - p = P() - mock_element = MockElement() - p.add(mock_element) - self.assertEqual(p.get_string(), '

mock_element

') - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/span_test.py b/tests/span_test.py deleted file mode 100644 index 675b04c..0000000 --- a/tests/span_test.py +++ /dev/null @@ -1,52 +0,0 @@ -import unittest -import io -import sys -from abc import ABC - -from winged.HTML.span import Span -from winged.core.element_abstract import ElementAbstract - - -class MockElement(ElementAbstract, ABC): - def get_string(self): - return "mock_element" - - -class SpanTest(unittest.TestCase): - def test_tag_attribute(self): - span = Span() - self.assertEqual(span.get_tag(), "span") - - def test_container_attribute(self): - span = Span() - self.assertTrue(span.is_container()) - - def test_form_element_attribute(self): - span = Span() - self.assertFalse(span.is_form_element()) - - def test_generate(self): - span = Span() - mock_element = MockElement() - span.add(mock_element) - - # Redireciona a saída padrão para um objeto StringIO para testar a saída gerada - captured_output = io.StringIO() - sys.stdout = captured_output - - span.generate() - - sys.stdout = sys.__stdout__ - - output = captured_output.getvalue().strip() - self.assertEqual(output, 'mock_element') - - def test_add_element(self): - span = Span() - mock_element = MockElement() - span.add(mock_element) - self.assertEqual(span.get_string(), 'mock_element') - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/table_test.py b/tests/table_test.py deleted file mode 100644 index da00162..0000000 --- a/tests/table_test.py +++ /dev/null @@ -1,34 +0,0 @@ -import unittest -from winged.HTML.table import Table -from winged.HTML.string import String - - -class TestTable(unittest.TestCase): - def test_table(self): - # Create a table - table = Table() - - # Add headers to the table - table.add_table_headers(["Name", "Age", "Height", "Location"]) - - # Add a row to the table - table.add_row() - - # Add data to the row - table.add_in_row(String("John")) - table.add_in_row(String("25")) - table.add_in_row(String("1.80")) - table.add_in_row(String("New York")) - - # Get the HTML string for the table - html = table.get_string() - - correct_html = ("
NameAgeHeightLocation
John251.80New York
") - - # Test if the generated HTML is correct - self.assertEqual(html, correct_html) - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/tests/tag_test.py b/tests/tag_test.py deleted file mode 100644 index 33f5634..0000000 --- a/tests/tag_test.py +++ /dev/null @@ -1,22 +0,0 @@ -import unittest -from winged.core.tag import Tag - - -class TagTest(unittest.TestCase): - def test_get_tag(self): - tag = Tag() - tag._tag = "div" - self.assertEqual(tag.get_tag(), "div") - - def test_get_attributes(self): - tag = Tag(("id", "my_div")) - self.assertEqual(tag.get_attributes().get_string(), 'id="my_div"') - - def test_add_attributes(self): - tag = Tag() - tag.add_attributes(("class", "my-class"), ("data-toggle", "modal")) - self.assertEqual(tag.get_attributes().get_string(), 'class="my-class" data-toggle="modal"') - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_accessibility.py b/tests/test_accessibility.py new file mode 100644 index 0000000..adb8072 --- /dev/null +++ b/tests/test_accessibility.py @@ -0,0 +1,85 @@ +"""The audit ROADMAP.md asks for. Each rule has a test that fires it and one that does not.""" + +from __future__ import annotations + +from winged import ( + H1, + H3, + A, + Body, + Button, + Div, + Document, + Element, + Form, + Head, + Iframe, + Img, + Input, + Label, + P, + render, +) +from winged.accessibility import audit + + +def rules(node: object) -> set[str]: + return {issue.rule for issue in audit(node)} + + +def test_a_clean_page_reports_nothing() -> None: + assert audit(Document(Head(), Body(H1("a"), P("b")))) == [] + + +def test_img_alt() -> None: + assert "img-alt" in rules(Element("img", src="x")) + assert "img-alt" not in rules(Img("x", "a photo")) + + +def test_iframe_title() -> None: + assert "iframe-title" in rules(Element("iframe", src="x")) + assert "iframe-title" not in rules(Iframe("x", "a map")) + + +def test_button_label() -> None: + assert "button-label" in rules(Button()) + assert "button-label" not in rules(Button("Send")) + assert "button-label" not in rules(Button(aria_label="Send")) + + +def test_link_text() -> None: + assert "link-text" in rules(A(Img("x", ""), href="/")) + assert "link-text" not in rules(A(Img("x", "Home"), href="/")) + + +def test_heading_order() -> None: + assert "heading-order" in rules(Div(H1("a"), H3("b"))) + assert "heading-order" not in rules(Div(H1("a"), P("b"))) + + +def test_html_lang() -> None: + assert "html-lang" in rules(Document(Head(), Body(), lang="")) + assert "html-lang" not in rules(Document(Head(), Body(), lang="pt-BR")) + + +def test_form_label() -> None: + assert "form-label" in rules(Form(Input(type_="text", id_="a"))) + assert "form-label" not in rules(Form(Label("A", for_="a"), Input(type_="text", id_="a"))) + assert "form-label" not in rules(Form(Input(type_="hidden"))) + + +def test_duplicate_id() -> None: + assert "duplicate-id" in rules(Div(Div(id_="x"), Div(id_="x"))) + assert "duplicate-id" not in rules(Div(Div(id_="x"), Div(id_="y"))) + + +def test_audit_never_mutates_the_tree() -> None: + tree = Div(H1("a"), Img("x", "y")) + before = render(tree) + audit(tree) + assert render(tree) == before + + +def test_issues_carry_a_path() -> None: + issues = audit(Div(Button())) + assert issues and "button" in issues[0].path diff --git a/tests/test_attribute.py b/tests/test_attribute.py new file mode 100644 index 0000000..440b877 --- /dev/null +++ b/tests/test_attribute.py @@ -0,0 +1,31 @@ +"""Ports Tests/WingedSwiftTests/HTMLTagTests.swift and HTML14FeaturesTests.swift.""" + +from __future__ import annotations + +from winged import Attribute + + +def test_value_is_escaped_at_construction() -> None: + assert Attribute("title", 'a "q" & v').value == "a "q" & v" + + +def test_boolean_renders_as_a_bare_key() -> None: + assert Attribute.boolean("required").render() == " required" + assert Attribute.boolean("required").value is None + + +def test_raw_does_not_escape() -> None: + assert Attribute.raw("property", "og:title").value == "og:title" + + +def test_rendering_twice_does_not_escape_twice() -> None: + attribute = Attribute("x", "a & b") + assert attribute.render() == attribute.render() == ' x="a & b"' + + +def test_attribute_is_frozen() -> None: + import pytest + + attribute = Attribute("x", "y") + with pytest.raises(Exception): # noqa: B017 -- dataclass raises FrozenInstanceError + attribute.key = "z" # type: ignore[misc] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..5ac6a56 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,183 @@ +"""Ports WingedCLITests/{NewCommandTests,ScaffolderTests,BuildCommandTests,HTTPServerTests}.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from winged.cli import _Handler, build_parser, find_project_root, main + + +def scaffold(tmp_path: Path, name: str = "demo") -> Path: + target = tmp_path / name + assert main(["new", name, "--path", str(target)]) == 0 + return target + + +def test_new_scaffolds_every_file(tmp_path: Path) -> None: + target = scaffold(tmp_path) + for expected in ("site.py", "layout.py", "assets/css/style.css", ".gitignore", "AGENTS.md"): + assert (target / expected).is_file(), expected + + +def test_new_scaffolds_an_agents_file(tmp_path: Path) -> None: + # WingedSwift's `new` scaffolds one too: a generated project should be workable by a + # coding agent from its first commit. + assert "winged build" in (scaffold(tmp_path) / "AGENTS.md").read_text() + + +def test_new_refuses_a_non_empty_directory(tmp_path: Path) -> None: + target = tmp_path / "demo" + target.mkdir() + (target / "keep.txt").write_text("x") + assert main(["new", "demo", "--path", str(target)]) == 1 + + +def test_generated_project_builds_from_the_filesystem_root(tmp_path: Path) -> None: + # The failure this guards against: a generator that resolves paths from the current + # directory passes every unit test and still fails for the user. + target = scaffold(tmp_path) + result = subprocess.run( + [sys.executable, str(target / "site.py")], + cwd="/", + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert (target / "dist" / "index.html").is_file() + assert (target / "dist" / "css" / "style.css").is_file() + assert (target / "dist" / "sitemap.xml").is_file() + + +def test_find_project_root_walks_up(tmp_path: Path) -> None: + target = scaffold(tmp_path) + nested = target / "a" / "b" + nested.mkdir(parents=True) + assert find_project_root(nested) == target.resolve() + + +def test_find_project_root_without_a_project(tmp_path: Path) -> None: + with pytest.raises(SystemExit, match=r"no site\.py"): + find_project_root(tmp_path) + + +@pytest.mark.parametrize( + ("suffix", "expected"), + [(".css", "text/css"), (".woff2", "font/woff2"), (".svg", "image/svg+xml")], +) +def test_mime_types(suffix: str, expected: str) -> None: + # mimetypes guesses several of these wrong, or not at all, depending on the platform. + handler = _Handler.__new__(_Handler) + assert handler.guess_type(f"x{suffix}") == expected + + +@pytest.mark.parametrize( + "attack", ["/../secret.txt", "/..%2fsecret.txt", "/a/../../secret.txt", "/./../secret.txt"] +) +def test_dot_dot_never_escapes_the_served_directory(tmp_path: Path, attack: str) -> None: + root = tmp_path / "dist" + root.mkdir() + (tmp_path / "secret.txt").write_text("nope") + + handler = _Handler.__new__(_Handler) + handler.directory = str(root) + translated = Path(handler.translate_path(attack)).resolve() + + assert translated.is_relative_to(root.resolve()) + assert not translated.exists() # so the request 404s rather than serving anything + + +def test_a_symlink_out_of_the_served_directory_is_not_followed(tmp_path: Path) -> None: + root = tmp_path / "dist" + root.mkdir() + secret = tmp_path / "secret.txt" + secret.write_text("nope") + (root / "link.txt").symlink_to(secret) + + handler = _Handler.__new__(_Handler) + handler.directory = str(root) + translated = Path(handler.translate_path("/link.txt")) + + assert translated.name == "__forbidden__" + assert not translated.exists() + + +def test_parser_exposes_the_three_commands() -> None: + parser = build_parser() + for command in ("new", "build", "serve"): + assert parser.parse_args([command] if command != "new" else [command, "x"]) + + +def test_build_writes_dist_and_reports_files( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + target = scaffold(tmp_path) + monkeypatch.chdir(target) + assert main(["build"]) == 0 + + out = capsys.readouterr().out + assert "index.html" in out and "total" in out + assert (target / "dist" / "index.html").is_file() + + +def test_build_from_a_subdirectory_writes_to_the_project_dist( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + target = scaffold(tmp_path) + nested = target / "a" / "b" + nested.mkdir(parents=True) + monkeypatch.chdir(nested) + + assert main(["build"]) == 0 + assert (target / "dist" / "index.html").is_file() + assert not (nested / "dist").exists() + + +def test_build_with_the_a11y_flag( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + target = scaffold(tmp_path) + monkeypatch.chdir(target) + assert main(["build", "--check-a11y"]) == 0 + assert "check-a11y" in capsys.readouterr().out + + +def test_no_subcommand_builds(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # WingedSwift makes `build` the default subcommand; so does this. + target = scaffold(tmp_path) + monkeypatch.chdir(target) + assert main([]) == 0 + assert (target / "dist" / "index.html").is_file() + + +def test_serve_without_a_build_fails_clearly( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + target = scaffold(tmp_path) + monkeypatch.chdir(target) + assert main(["serve"]) == 1 + assert "winged build" in capsys.readouterr().err + + +def test_serve_reports_an_occupied_port( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + import socket + + target = scaffold(tmp_path) + monkeypatch.chdir(target) + main(["build"]) + + holder = socket.socket() + holder.bind(("127.0.0.1", 0)) + holder.listen(1) + port = holder.getsockname()[1] + try: + assert main(["serve", "--port", str(port)]) == 1 + assert f"cannot bind port {port}" in capsys.readouterr().err + finally: + holder.close() diff --git a/tests/test_document.py b/tests/test_document.py new file mode 100644 index 0000000..77c945c --- /dev/null +++ b/tests/test_document.py @@ -0,0 +1,55 @@ +"""Ports DocumentTests.swift, LayoutTests.swift and RenderOptionsTests.swift.""" + +from __future__ import annotations + +from winged import ( + H1, + Body, + Document, + Head, + Layout, + Main, + P, + RenderOptions, + Title, + render, + render_many, +) +from winged.core.render import Node + + +def test_document_owns_the_doctype() -> None: + assert render(Document(Head(), Body())).startswith('\n') + + +def test_lang_is_on_the_html_element() -> None: + out = render(Document(Head(), Body(), lang="pt-BR")) + assert '' in out + assert out.count(" None: + out = render(Document(Head(Title("Home")), Body(H1("Hi"))), RenderOptions.pretty_()) + assert out == ( + '\n\n \n Home\n \n' + " \n

Hi

\n \n" + ) + + +class _Layout: + def render(self, content: Node) -> Node: + return Main(content) + + +def test_a_plain_class_satisfies_the_protocol() -> None: + assert isinstance(_Layout(), Layout) + + +def test_render_many_injects_no_wrapper() -> None: + assert render(render_many(_Layout(), [P("a"), P("b")])) == "

a

b

" + + +def test_render_options_are_values() -> None: + assert RenderOptions.compact().pretty is False + assert RenderOptions.pretty_().pretty is True + assert RenderOptions(indent="\t").indent == "\t" diff --git a/tests/test_element.py b/tests/test_element.py new file mode 100644 index 0000000..c682d0d --- /dev/null +++ b/tests/test_element.py @@ -0,0 +1,70 @@ +"""Ports HTMLTagTests, HTML5TagsTests, WhitespaceTests and PrettyPrintTests.""" + +from __future__ import annotations + +import pytest + +from winged import Br, Code, Col, Div, Element, Img, Input, P, Pre, RenderOptions, Strong, render + +PRETTY = RenderOptions.pretty_() + + +def test_void_elements_have_no_closing_tag() -> None: + # 0.1.0 rendered and . + assert render(Br()) == "
" + assert render(Col()) == "" + + +def test_void_element_refuses_a_child() -> None: + # 0.1.0 accepted the child and silently discarded it. + with pytest.raises(ValueError, match="void element"): + Element("br", P("x")) + + +def test_xhtml_self_closing() -> None: + xhtml = RenderOptions(xhtml_self_closing=True) + assert render(Img("x", "y"), xhtml) == 'y' + + +def test_state_is_per_instance() -> None: + # 0.1.0 held _tag, _attributes and _children as class attributes. + first, second = Div(), Div() + first.child(P("x")) + assert render(second) == "
" + + +def test_rendering_is_idempotent() -> None: + # 0.1.0's Table.get_string() appended its rows on every call. + tree = Div(P("x")) + assert render(tree) == render(tree) == "

x

" + + +def test_keyword_attributes() -> None: + assert render(Div(cls="a", data_user_id="7", hidden=True)) == ( + '' + ) + + +def test_false_and_none_attributes_are_omitted() -> None: + assert render(Input(type_="text", disabled=False, placeholder=None)) == '' + + +def test_name_attribute_does_not_collide_with_the_tag_name() -> None: + assert render(Input(type_="email", name="email")) == '' + + +def test_text_children_render_inline_in_pretty_mode() -> None: + assert render(P("Fuel & chain"), PRETTY) == "

Fuel & chain

" + + +def test_element_children_are_indented() -> None: + assert render(Div(P("a"), P("b")), PRETTY) == "
\n

a

\n

b

\n
" + + +def test_whitespace_sensitive_subtrees_stay_compact() -> None: + # Indentation injected into
 changes what the browser displays.
+    assert render(Pre(Code("  two\n  lines")), PRETTY) == "
  two\n  lines
" + + +def test_mixed_content() -> None: + assert render(P("Hello ", Strong("world"))) == "

Hello world

" diff --git a/tests/test_escape.py b/tests/test_escape.py new file mode 100644 index 0000000..03c718c --- /dev/null +++ b/tests/test_escape.py @@ -0,0 +1,42 @@ +"""Ports Tests/WingedSwiftTests/HTMLEscapeTests.swift.""" + +from __future__ import annotations + +import pytest + +from winged import escape_attribute, escape_text, escape_xml + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("", "<script>alert('XSS')</script>"), + ("a & b", "a & b"), + ('say "hi"', "say "hi""), + ("", ""), + ("plain", "plain"), + ], +) +def test_escape_text(raw: str, expected: str) -> None: + assert escape_text(raw) == expected + + +def test_ampersand_is_escaped_first() -> None: + # Any other order produces "&lt;". + assert escape_text("<") == "<" + assert escape_text("<") == "&lt;" + + +def test_slashes_are_left_alone_by_default() -> None: + # WingedSwift escaped these unconditionally until 1.5.0, turning every date into soup. + assert escape_text("2026/09/18") == "2026/09/18" + assert escape_text("a/b", escape_slashes=True) == "a/b" + + +def test_attribute_context_keeps_angle_brackets() -> None: + escaped = escape_attribute('a "quoted" & value') + assert escaped == "a "quoted" & value" + + +def test_escape_xml() -> None: + assert escape_xml("a & b 'd'") == "a & b <c> 'd'" diff --git a/tests/test_golden.py b/tests/test_golden.py new file mode 100644 index 0000000..414774b --- /dev/null +++ b/tests/test_golden.py @@ -0,0 +1,79 @@ +"""Byte-for-byte parity with Winged-Swift's frozen fixtures. + +This is what makes "parity with Winged-Swift" a test rather than a claim. The four files +in ``tests/fixtures`` are copies of ``Winged-Swift/Tests/WingedSwiftTests/Fixtures``, so +the suite runs on a clone with no Swift checkout. + +``WINGED_UPDATE_FIXTURES=1 pytest`` rewrites a fixture **and fails the run**, saying to +re-run and compare. A regeneration switch that silently passes is how a golden suite stops +meaning anything. +""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from tests.marketing import marketing_page +from winged import RenderOptions, render +from winged.feed import RssGenerator, RssItem, rfc822 +from winged.sitemap import SitemapGenerator, SitemapUrl + +FIXTURES = Path(__file__).parent / "fixtures" + + +def check(name: str, produced: str) -> None: + path = FIXTURES / name + if os.environ.get("WINGED_UPDATE_FIXTURES"): + path.write_text(produced, encoding="utf-8", newline="\n") + pytest.fail(f"wrote {name} — re-run the tests to compare against it") + assert produced == path.read_text(encoding="utf-8") + + +def test_marketing_compact() -> None: + check("marketing-compact.html", render(marketing_page())) + + +def test_marketing_pretty() -> None: + check("marketing-pretty.html", render(marketing_page(), RenderOptions.pretty_())) + + +def test_sitemap() -> None: + generator = SitemapGenerator("https://ridekeeper.example") + produced = generator.generate( + [ + SitemapUrl("https://ridekeeper.example/", changefreq="weekly", priority=1.0), + SitemapUrl( + "https://ridekeeper.example/pricing?plan=pro&billing=year", + lastmod="2026-08-11", + changefreq="monthly", + priority=0.7, + ), + ] + ) + check("sitemap.xml", produced) + + +def test_feed() -> None: + generator = RssGenerator( + title="RideKeeper", + link="https://ridekeeper.example", + description="Release notes", + language="pt-BR", + self_url="https://ridekeeper.example/feed.xml", + ) + produced = generator.generate( + [ + RssItem( + title="1.2 — tyres & chain", + link="https://ridekeeper.example/blog/1-2", + description="Tyre pressure log chain reminders", + pub_date=rfc822(datetime(2026, 8, 11, 10, 0, 0, tzinfo=timezone.utc)), + categories=["release"], + ) + ] + ) + check("feed.xml", produced) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..e1c9f54 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,38 @@ +"""Ports CSSHelpersTests.swift and AttributeHelpersTests.swift.""" + +from __future__ import annotations + +from winged import Div, render + + +def test_add_class_appends() -> None: + assert render(Div().add_class("a").add_class("b")) == '
' + + +def test_add_classes() -> None: + assert render(Div().add_classes("a", "b", "c")) == '
' + + +def test_class_values_cannot_escape_the_attribute() -> None: + # WingedSwift 1.5.0 fixed this injection: the value used to be written verbatim, so a + # quote in dynamic data closed the attribute and opened a new one. + out = render(Div().add_class('x" onclick="evil()')) + assert out == '
' + # The quotes are entities, so there is exactly one attribute on the element. + assert out.count('="') == 1 + + +def test_no_double_escaping_on_repeated_add_class() -> None: + assert render(Div().add_class("a & b").add_class("c")) == '
' + + +def test_set_id_style_role() -> None: + element = Div().set_id("x").set_style("color: red").set_role("banner") + assert render(element) == '' + + +def test_data_and_aria_attrs_are_deterministic() -> None: + # WingedSwift takes a Dictionary here, so its order varies between runs. Insertion + # order here, which is what makes golden files possible. + element = Div().data_attrs({"b": "2", "a": "1"}).aria_attrs({"label": "x"}) + assert render(element) == '
' diff --git a/tests/test_nodes.py b/tests/test_nodes.py new file mode 100644 index 0000000..ab3967b --- /dev/null +++ b/tests/test_nodes.py @@ -0,0 +1,41 @@ +"""Ports Tests/WingedSwiftTests/FragmentTests.swift.""" + +from __future__ import annotations + +import pytest + +from winged import Comment, Div, Fragment, P, RawHtml, RenderOptions, render + +PRETTY = RenderOptions.pretty_() + + +def test_fragment_is_transparent() -> None: + assert render(Div(Fragment(P("a"), P("b")))) == render(Div(P("a"), P("b"))) + + +def test_fragment_keeps_indentation_and_raw_html_does_not() -> None: + assert render(Div(Fragment(P("a"), P("b"))), PRETTY) == render(Div(P("a"), P("b")), PRETTY) + assert render(Div(RawHtml("

a

b

")), PRETTY) == "

a

b

" + + +def test_bare_strings_are_escaped_and_raw_html_is_not() -> None: + assert render(Div("x")) == "
<b>x</b>
" + assert render(Div(RawHtml("x"))) == "
x
" + + +def test_comment() -> None: + assert render(Comment("note")) == "" + + +def test_comment_rejects_a_double_hyphen() -> None: + with pytest.raises(ValueError, match=r"--"): + Comment("a -- b") + + +def test_none_children_are_dropped() -> None: + assert render(Div(None, P("x"))) == "

x

" + + +def test_iterables_are_flattened() -> None: + assert render(Div([P("a"), P("b")])) == "

a

b

" + assert render(Div(P(str(n)) for n in range(2))) == "

0

1

" diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..287d2d2 --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,40 @@ +"""The public surface: what `import winged` and `from winged.prelude import *` give you.""" + +from __future__ import annotations + +import winged + + +def test_star_import_is_bounded_by_all() -> None: + namespace: dict[str, object] = {} + exec("from winged import *", namespace) + # __version__ is a dunder and is deliberately in __all__, so it must not be filtered. + exported = set(namespace) - {"__builtins__"} + assert exported == set(winged.__all__) + + +def test_prelude_re_exports_the_same_names() -> None: + from winged import prelude + + assert set(prelude.__all__) == set(winged.__all__) + + +def test_version_is_a_string() -> None: + assert isinstance(winged.__version__, str) + assert winged.__version__ + + +def test_no_sys_path_mutation() -> None: + # 0.1.0's winged/__init__.py was two lines: `import sys` and `sys.path.insert(0, "")`, + # which put the current directory on the import path for anyone who imported the + # library. Assert the effect, not the spelling -- the docstring mentions it. + import sys + + assert "" not in sys.path[:1] + source = __import__("pathlib").Path(winged.__file__).read_text() + assert "sys.path.insert" not in source + + +def test_core_names_are_exported() -> None: + for name in ("Document", "RenderOptions", "render", "Fragment", "RawHtml", "escape_text"): + assert hasattr(winged, name), name diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..a62631c --- /dev/null +++ b/tests/test_performance.py @@ -0,0 +1,39 @@ +"""A regression guard, not a benchmark. + +Ports ``Winged-Swift/Tests/WingedSwiftTests/RenderPerformanceTests.swift``. + +What it guards against is a return to per-node string concatenation, which is quadratic +and is exactly what 0.1.0's ``get_string()`` did -- every node built a new string from its +children's strings. The bound is generous on purpose: CI runners vary, and a flaky +performance test gets disabled, which is worse than not having one. +""" + +from __future__ import annotations + +import time + +from winged import RenderOptions, Table, Tbody, Td, Tr, render + + +def big_table(rows: int = 1500) -> Table: + return Table(Tbody(*(Tr(Td(str(n)), Td("x"), Td("y"), Td("z")) for n in range(rows)))) + + +def test_rendering_a_large_tree_stays_linear() -> None: + tree = big_table() + start = time.perf_counter() + out = render(tree) + elapsed = time.perf_counter() - start + + assert elapsed < 2.0, f"rendering took {elapsed:.2f}s" + # A render that got fast by producing less is not a pass. + assert out.startswith("") + assert out.endswith("
0
") + assert len(out) > 70_000 + + +def test_pretty_rendering_also_completes() -> None: + start = time.perf_counter() + out = render(big_table(500), RenderOptions.pretty_()) + assert time.perf_counter() - start < 2.0 + assert out.count("") == 2000 diff --git a/tests/test_seo.py b/tests/test_seo.py new file mode 100644 index 0000000..43f81f6 --- /dev/null +++ b/tests/test_seo.py @@ -0,0 +1,86 @@ +"""Ports Tests/WingedSwiftTests/SEOTests.swift.""" + +from __future__ import annotations + +from winged import render +from winged.seo import SeoBuilder, common, open_graph, open_graph_article, twitter_card + + +def test_open_graph_uses_property() -> None: + out = render(open_graph(title="T", description="D", image="I", url="U")) + assert '' in out + assert 'name="og:title"' not in out + + +def test_twitter_uses_name() -> None: + out = render(twitter_card(title="T", description="D", image="I")) + assert '' in out + + +def test_unset_optionals_emit_nothing() -> None: + out = render(open_graph(title="T", description="D", image="I", url="U")) + assert "og:site_name" not in out + assert 'content=""' not in out + + +def test_common_does_not_emit_a_title() -> None: + # Matching WingedSwift: a page places its own , so a meta helper injecting one + # into the middle of the head would make ordering surprising. + assert "<title>" not in render(common(title="T", description="D")) + + +def test_values_are_escaped() -> None: + out = render(common(title="T", description='a "quoted" & <tagged> value')) + assert ""quoted" &" in out + + +def test_article_tags_render_in_order() -> None: + out = render(open_graph_article(tags=["a", "b"])) + assert out.index('content="a"') < out.index('content="b"') + + +def test_builder_order_is_common_then_og_then_twitter() -> None: + out = render(SeoBuilder(title="T", description="D", image="I", url="U").build()) + assert out.index("charset") < out.index("og:title") < out.index("twitter:card") + + +def test_optional_open_graph_fields() -> None: + out = render( + open_graph( + title="T", + description="D", + image="I", + url="U", + site_name="S", + locale="pt_BR", + type_="article", + ) + ) + assert 'property="og:site_name" content="S"' in out + assert 'property="og:locale" content="pt_BR"' in out + assert 'property="og:type" content="article"' in out + + +def test_optional_twitter_fields() -> None: + out = render(twitter_card(title="T", description="D", image="I", site="@s", creator="@c")) + assert 'name="twitter:site" content="@s"' in out + assert 'name="twitter:creator" content="@c"' in out + + +def test_common_optional_fields() -> None: + out = render(common(title="T", description="D", keywords=["a", "b"], author="Me")) + assert 'content="a, b"' in out + assert 'name="author" content="Me"' in out + + +def test_article_metadata() -> None: + out = render( + open_graph_article( + published_time="2026-01-01", + modified_time="2026-02-01", + author="Me", + section="Tech", + ) + ) + for key in ("published_time", "modified_time", "author", "section"): + assert f"article:{key}" in out diff --git a/tests/test_sitemap_feed.py b/tests/test_sitemap_feed.py new file mode 100644 index 0000000..8abb180 --- /dev/null +++ b/tests/test_sitemap_feed.py @@ -0,0 +1,79 @@ +"""Ports SitemapGeneratorTests.swift and RSSGeneratorTests.swift.""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET +from datetime import datetime, timezone + +import pytest + +from winged.feed import RssGenerator, RssItem, rfc822 +from winged.sitemap import SitemapGenerator, SitemapUrl + + +def test_sitemap_escapes_ampersands() -> None: + out = SitemapGenerator("https://x.example").generate([SitemapUrl("/a?x=1&y=2")]) + assert "&" in out + assert "?x=1&y=2" not in out + + +def test_priority_keeps_one_decimal_place() -> None: + out = SitemapGenerator("https://x.example").generate([SitemapUrl("/", priority=0.8)]) + assert "<priority>0.8</priority>" in out + + +def test_unset_optionals_emit_no_element() -> None: + out = SitemapGenerator("https://x.example").generate([SitemapUrl("/")]) + assert "<lastmod>" not in out and "<changefreq>" not in out and "<priority>" not in out + + +def test_invalid_changefreq_raises_at_construction() -> None: + with pytest.raises(ValueError, match="changefreq"): + SitemapUrl("/", changefreq="fortnightly") + + +def test_invalid_priority_raises() -> None: + with pytest.raises(ValueError, match="priority"): + SitemapUrl("/", priority=2.0) + + +def test_relative_loc_is_resolved_against_the_base() -> None: + out = SitemapGenerator("https://x.example/").generate([SitemapUrl("/a")]) + assert "<loc>https://x.example/a</loc>" in out + + +def test_sitemap_is_valid_xml() -> None: + out = SitemapGenerator("https://x.example").generate([SitemapUrl("/", priority=1.0)]) + ET.fromstring(out) + + +def test_feed_is_valid_rss() -> None: + out = RssGenerator(title="T", link="L", description="D").generate( + [RssItem(title="a", link="b", description="c")] + ) + root = ET.fromstring(out) + assert root.tag == "rss" and root.attrib["version"] == "2.0" + + +def test_guid_defaults_to_the_link() -> None: + out = RssGenerator(title="T", link="L", description="D").generate( + [RssItem(title="a", link="https://x.example/p", description="c")] + ) + assert '<guid isPermaLink="true">https://x.example/p</guid>' in out + + +def test_markup_in_a_description_round_trips() -> None: + out = RssGenerator(title="T", link="L", description="D").generate( + [RssItem(title="a", link="b", description="<b>x</b> & y")] + ) + item = ET.fromstring(out).find("./channel/item/description") + assert item is not None and item.text == "<b>x</b> & y" + + +def test_rfc822_is_not_iso8601() -> None: + formatted = rfc822(datetime(2026, 8, 11, 10, 0, 0, tzinfo=timezone.utc)) + assert formatted == "Tue, 11 Aug 2026 10:00:00 +0000" + + +def test_naive_datetimes_are_treated_as_utc() -> None: + assert rfc822(datetime(2026, 8, 11, 10, 0, 0)).endswith("+0000") diff --git a/tests/test_ssg.py b/tests/test_ssg.py new file mode 100644 index 0000000..baf68a5 --- /dev/null +++ b/tests/test_ssg.py @@ -0,0 +1,62 @@ +"""Ports Tests/WingedSwiftTests/StaticSiteGeneratorTests.swift.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from winged import H1, Body, Document, Head +from winged.ssg import StaticSiteGenerator + + +def page() -> Document: + return Document(Head(), Body(H1("Hi"))) + + +def test_intermediate_directories_are_created(tmp_path: Path) -> None: + site = StaticSiteGenerator(tmp_path) + site.generate(page(), "a/b/c.html") + assert (tmp_path / "a" / "b" / "c.html").is_file() + + +def test_output_uses_unix_line_endings(tmp_path: Path) -> None: + site = StaticSiteGenerator(tmp_path) + site.generate(page(), "index.html") + assert b"\r\n" not in (tmp_path / "index.html").read_bytes() + + +def test_generate_multiple(tmp_path: Path) -> None: + StaticSiteGenerator(tmp_path).generate_multiple({"a.html": page(), "b.html": page()}) + assert (tmp_path / "a.html").is_file() and (tmp_path / "b.html").is_file() + + +def test_clean_empties_the_directory(tmp_path: Path) -> None: + (tmp_path / "old.html").write_text("x") + (tmp_path / "sub").mkdir() + StaticSiteGenerator(tmp_path).clean() + assert list(tmp_path.iterdir()) == [] + + +def test_clean_refuses_the_filesystem_root() -> None: + with pytest.raises(ValueError, match="refusing to clean"): + StaticSiteGenerator("/").clean() + + +def test_clean_refuses_the_home_directory() -> None: + with pytest.raises(ValueError, match="refusing to clean"): + StaticSiteGenerator(Path.home()).clean() + + +def test_writing_outside_the_output_directory_is_refused(tmp_path: Path) -> None: + site = StaticSiteGenerator(tmp_path / "dist") + with pytest.raises(ValueError, match="outside the output directory"): + site.write_file("x", "../escaped.html") + + +def test_copy_asset(tmp_path: Path) -> None: + source = tmp_path / "style.css" + source.write_text("body{}") + site = StaticSiteGenerator(tmp_path / "dist") + site.copy_asset(source, "css/style.css") + assert (tmp_path / "dist" / "css" / "style.css").read_text() == "body{}" diff --git a/tests/test_tag_catalog.py b/tests/test_tag_catalog.py new file mode 100644 index 0000000..a94f87b --- /dev/null +++ b/tests/test_tag_catalog.py @@ -0,0 +1,66 @@ +"""Ports Tests/WingedSwiftTests/TagCatalogTests.swift. + +Every element in the table is constructed and rendered. 0.1.0 had 49 tag modules and tests +for four of them; this is the test that makes "every tag works" a fact rather than a hope, +and it is why adding a tag without a test is impossible. +""" + +from __future__ import annotations + +import pytest + +import winged +from winged._tagtable import TAGS +from winged.core.render import render +from winged.core.tags import VOID_ELEMENTS, WHITESPACE_SENSITIVE +from winged.elements import Iframe, Img + + +@pytest.mark.parametrize(("name", "tag"), [(n, t) for n, t, _ in TAGS], ids=[n for n, _, _ in TAGS]) +def test_every_element_renders(name: str, tag: str) -> None: + element_type = getattr(winged, name) + element = element_type() if tag in VOID_ELEMENTS else element_type("x") + out = render(element) + + if tag in VOID_ELEMENTS: + assert out == f"<{tag}>" + else: + assert out == f"<{tag}>x</{tag}>" + + +@pytest.mark.parametrize(("name", "tag"), [(n, t) for n, t, _ in TAGS], ids=[n for n, _, _ in TAGS]) +def test_every_element_takes_attributes(name: str, tag: str) -> None: + element = ( + getattr(winged, name)(**{"cls": "c"}) + if tag in VOID_ELEMENTS + else getattr(winged, name)(cls="c") + ) + assert 'class="c"' in render(element) + + +def test_every_element_is_exported() -> None: + for name, _, _ in TAGS: + assert name in winged.__all__, f"{name} is missing from winged.__all__" + + +def test_the_required_argument_elements() -> None: + # Img needs alt and Iframe needs title, by construction. That is an accessibility + # guarantee the signature enforces, as it is in WingedSwift. + assert render(Img("/a.png", "A photo")) == '<img src="/a.png" alt="A photo">' + assert render(Iframe("/a", "A map")) == '<iframe src="/a" title="A map"></iframe>' + + with pytest.raises(TypeError): + Img("/a.png") # type: ignore[call-arg] + with pytest.raises(TypeError): + Iframe("/a") # type: ignore[call-arg] + + +def test_the_tag_sets_are_consistent_with_the_table() -> None: + # "img" and "iframe" live in winged._special, because their signatures require an + # argument; everything else comes from the table. + known = {tag for _, tag, _ in TAGS} | {"img", "iframe"} + + # Every void element the renderer knows about is reachable, except <area> and + # <param>, which WingedSwift does not ship either. + assert VOID_ELEMENTS - known == {"area", "param"} + assert known >= WHITESPACE_SENSITIVE From a9a83ed46dd13514922cb97895ce41669b998187 Mon Sep 17 00:00:00 2001 From: Michel Lutz <michel_lutz@icloud.com> Date: Sat, 19 Sep 2026 00:02:53 -0300 Subject: [PATCH 3/9] Change: CI that runs on pull requests, lints and gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old workflow triggered on push to main only, so no pull request was ever tested. It ran one Python 3.11 job on actions/checkout@v2 and setup-python@v2 — both deprecated — with no lint, no type check and no build. - ci.yml: 3.10-3.13 on ubuntu and macos, with ruff, mypy --strict, generated- file freshness and verify.sh as gating jobs, plus a concurrency group so a superseded push is cancelled. - release.yml: on tag v*.*.*, verifies, extracts the version's CHANGELOG section, and publishes to PyPI via trusted publishing — no token in secrets. - dependabot.yml gains the github-actions ecosystem. Its absence is exactly why the workflow was still on @v2 three years later. - Issue templates rewritten: bug_report asked a library's users for their Browser, Smartphone and Device, and custom.md was an empty placeholder. - Adds a PR template, CODEOWNERS and codecov.yml. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug_report.md | 45 ++++------- .github/ISSUE_TEMPLATE/config.yml | 5 ++ .github/ISSUE_TEMPLATE/custom.md | 10 --- .github/ISSUE_TEMPLATE/feature_request.md | 26 +++--- .github/ISSUE_TEMPLATE/parity_gap.md | 17 ++++ .github/PULL_REQUEST_TEMPLATE.md | 18 +++++ .github/dependabot.yml | 14 +++- .github/workflows/ci.yml | 96 +++++++++++++++++++++++ .github/workflows/python-package.yml | 34 -------- .github/workflows/release.yml | 42 ++++++++++ codecov.yml | 20 +++++ 12 files changed, 240 insertions(+), 88 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/custom.md create mode 100644 .github/ISSUE_TEMPLATE/parity_gap.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/python-package.yml create mode 100644 .github/workflows/release.yml create mode 100644 codecov.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..c71a216 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @micheltlutz diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dd84ea7..9d7c855 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,38 +1,27 @@ --- name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - +about: Markup that is wrong, or an error you did not expect +labels: bug --- -**Describe the bug** -A clear and concise description of what the bug is. +## What happened + +<!-- The markup produced, or the traceback. --> + +## What you expected -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error +<!-- The markup you expected instead. --> -**Expected behavior** -A clear and concise description of what you expected to happen. +## Minimal reproduction -**Screenshots** -If applicable, add screenshots to help explain your problem. +```python +from winged import Div, render -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] +print(render(Div("..."))) +``` -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] +## Environment -**Additional context** -Add any other context about the problem here. +- Winged-Python version: <!-- python -c "import winged; print(winged.__version__)" --> +- Python version: <!-- python --version --> +- OS: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1787bba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Question + url: https://github.com/micheltlutz/Winged-Python/discussions + about: Ask how to do something with the library. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md deleted file mode 100644 index 48d5f81..0000000 --- a/.github/ISSUE_TEMPLATE/custom.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: Custom issue template -about: Describe this issue template's purpose here. -title: '' -labels: '' -assignees: '' - ---- - - diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7..f77181b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,20 +1,20 @@ --- name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - +about: Something the library cannot express, or expresses badly +labels: enhancement --- -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +## The problem + +<!-- What are you trying to build, and what stops you? Describe the problem before the +solution — ROADMAP.md is written the same way. --> + +## What you do today -**Describe the solution you'd like** -A clear and concise description of what you want to happen. +<!-- The workaround, if there is one. --> -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. +## What you would like to write -**Additional context** -Add any other context or screenshots about the feature request here. +```python +# The call site you wish existed. +``` diff --git a/.github/ISSUE_TEMPLATE/parity_gap.md b/.github/ISSUE_TEMPLATE/parity_gap.md new file mode 100644 index 0000000..61dda3e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/parity_gap.md @@ -0,0 +1,17 @@ +--- +name: Parity gap with Winged-Swift +about: Winged-Swift does something this port does not, or does differently +labels: parity +--- + +## The Winged-Swift behaviour + +<!-- Name the file: Sources/WingedSwift/... --> + +## What Winged-Python does instead + +<!-- Rendered output from both, if you have it. --> + +## Is the difference deliberate? + +<!-- Check PORTING.md first — some differences are decisions, and it lists them. --> diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4b6b0bd --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## What changed + +<!-- One or two sentences. What does this do that the previous code did not? --> + +## Why + +<!-- The problem, not the patch. Link the issue if there is one: Closes #123 --> + +## Markup impact + +<!-- If rendered output changes, paste before and after. If it does not, say "none". --> + +## Checklist + +- [ ] `./scripts/verify.sh` passes +- [ ] Tests cover the change, and assert on rendered strings rather than tree shape +- [ ] `CHANGELOG.md` has an entry under `## [Unreleased]` +- [ ] If an element was added: a row in `winged/_tagtable.py`, and both generators re-run diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 43b9089..ff2b7e0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,15 @@ version: 2 updates: - - package-ecosystem: "pip" - directory: "/" + - package-ecosystem: pip + directory: / schedule: - interval: "weekly" + interval: weekly + open-pull-requests-limit: 10 + + # Without this, the workflows stay pinned wherever they were written. That is exactly + # how Winged-Python ended up on actions/checkout@v2 three years after v4 shipped. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c56a5bc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,96 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +# A new push supersedes the run already in flight. Winged-Swift's workflows have no +# concurrency group, so its queue fills with runs nobody will read. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (${{ matrix.os }}, ${{ matrix.python }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + - run: pip install -e . pytest pytest-cov + - run: pytest -q + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install ruff + # Gating, deliberately. Winged-Swift marks its SwiftLint job continue-on-error, so + # its badge can be green while lint is failing. + - run: ruff check --output-format=github . + - run: ruff format --check . + + types: + name: Types + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e . mypy pytest + - run: mypy + + generated: + name: Generated files are current + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e . + # A tag added without regenerating fails here rather than drifting silently. + - run: python scripts/generate_elements.py --check + - run: python scripts/generate_tag_catalog.py --check + + verify: + name: verify.sh + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e . pytest pytest-cov ruff mypy build + - run: ./scripts/verify.sh + + coverage: + name: Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e . pytest pytest-cov + - run: pytest -q + - uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + fail_ci_if_error: false diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml deleted file mode 100644 index f17dcaf..0000000 --- a/.github/workflows/python-package.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Python Tests with pytest - -on: - push: - branches: - - main # Substitua 'main' pelo nome da sua branch principal - -jobs: - test: - name: Test on Python 3.11 - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Python 3.11 - uses: actions/setup-python@v2 - with: - python-version: 3.11 - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run tests with pytest and coverage - run: | - pytest - - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..40ab24f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release + +on: + push: + tags: ["v*.*.*"] + +jobs: + release: + name: Build, verify and publish + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + id-token: write # PyPI trusted publishing. No long-lived token in secrets. + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: pip install -e . pytest pytest-cov ruff mypy build + - name: Verify + run: ./scripts/verify.sh + + - name: Build distributions + run: | + rm -rf dist + python -m build + + - name: Extract this version's changelog section + id: notes + run: | + VERSION="${GITHUB_REF_NAME#v}" + sed -n "/## \[$VERSION\]/,/## \[/p" CHANGELOG.md | sed '$d' > RELEASE_NOTES.md + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - uses: softprops/action-gh-release@v2 + with: + body_path: RELEASE_NOTES.md + files: dist/* + + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..e6eab48 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,20 @@ +# The CLI's process plumbing is excluded for the same reason Winged-Swift excludes its +# own: it is argument parsing, a socket server and a file-watch loop, with no logic left +# to test once the commands themselves are covered — and it is exercised end to end by +# scripts/verify.sh, which runs `winged new` and `winged build` from `/` and produces no +# coverage data. +# +# Nothing else is ignored, and the thresholds stay at the project's defaults: red here +# means write a test. +ignore: + - "src/winged/templates/**" + +coverage: + status: + project: + default: + target: auto + threshold: 1% + patch: + default: + target: 80% From 4240f4b7f4779f6aaefb0a3bed96291b2a9bb1c4 Mon Sep 17 00:00:00 2001 From: Michel Lutz <michel_lutz@icloud.com> Date: Sat, 19 Sep 2026 00:03:04 -0300 Subject: [PATCH 4/9] Docs: the 1.0 documentation set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Winged-Python shipped 5 documents against Winged-Swift's 11, and the README showed a pretty-printed "Output" block the library could not produce — 0.1.0 emitted one unindented line. - README rewritten. Every output block in it is copied from a real run. - GETTING_STARTED.md: four ways in, then deployment to Pages/Netlify/Vercel. - MIGRATION.md: 0.1.0 to 1.0.0, a row per removed name, ending in a grep checklist. - PORTING.md: the Winged-Swift map, and the seven differences that are decisions rather than accidents. - ROADMAP.md, problem-first, including this port's own known limitations. - CHANGELOG.md, and AGENTS.md with CLAUDE.md as a five-line pointer to it. - docs/: a generated tag catalogue, recipes and pitfalls. The Claude Code skill references all three by symlink, so there is no second copy of the tag list to drift. SECURITY.md had a stray ] in its mailto, no version table, and boilerplate about "strong authentication" for a string-building library — while omitting the only thing that matters. It now documents exactly what is escaped, what is not, and what escaping does not protect you from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .claude/skills/winged/SKILL.md | 56 ++++ .claude/skills/winged/references/pitfalls.md | 1 + .claude/skills/winged/references/recipes.md | 1 + .../skills/winged/references/tag-catalog.md | 1 + AGENTS.md | 157 +++++++++ CHANGELOG.md | 96 ++++++ CLAUDE.md | 5 + CONTRIBUTING.md | 136 +++++--- GETTING_STARTED.md | 213 ++++++++++++ MIGRATION.md | 140 ++++++++ PORTING.md | 144 ++++++++ README.md | 314 ++++++++++++------ ROADMAP.md | 55 +++ SECURITY.md | 57 +++- docs/pitfalls.md | 105 ++++++ docs/recipes.md | 208 ++++++++++++ docs/tag-catalog.md | 115 +++++++ 17 files changed, 1649 insertions(+), 155 deletions(-) create mode 100644 .claude/skills/winged/SKILL.md create mode 120000 .claude/skills/winged/references/pitfalls.md create mode 120000 .claude/skills/winged/references/recipes.md create mode 120000 .claude/skills/winged/references/tag-catalog.md create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 GETTING_STARTED.md create mode 100644 MIGRATION.md create mode 100644 PORTING.md create mode 100644 ROADMAP.md create mode 100644 docs/pitfalls.md create mode 100644 docs/recipes.md create mode 100644 docs/tag-catalog.md diff --git a/.claude/skills/winged/SKILL.md b/.claude/skills/winged/SKILL.md new file mode 100644 index 0000000..149a8b3 --- /dev/null +++ b/.claude/skills/winged/SKILL.md @@ -0,0 +1,56 @@ +--- +name: winged +description: Write or extend Winged-Python, a dependency-free Python DSL that builds HTML strings. Use when working with winged, Winged-Python, `from winged import`, Element/Document/Fragment/RawHtml/RenderOptions, the `winged` CLI (new/build/serve), or any task that generates HTML, a static site, a sitemap, or an RSS feed with this library. +--- + +# Winged-Python + +A DSL that builds an HTML **string**. No DOM, no browser, no server, no runtime +dependencies. + +## Which workflow are you in? + +**A. Using the library** — building pages, a site, a feed. Read the rules below, then +`references/recipes.md` for the shape you need. + +**B. Extending the library** — changing `src/winged/`. Read `AGENTS.md` at the repository +root; it has the element-adding contract and the "before you say you are done" gate. + +## Rules + +1. **Children are positional, attributes are keyword.** `Div(P("a"), cls="card")`. +2. **Text is escaped.** Pass a plain `str`. Never pre-escape — you get `&lt;`. +3. **`RawHtml` is the only opt-out**, and never for anything from data. +4. **Void elements take no children** and raise if given one: `Br`, `Img`, `Input`, + `Link`, `Meta`, `Hr`, `Col`, `Source`, `Track`, `Wbr`, `Base`, `Embed`. +5. **`Fragment` groups; `RawHtml` injects.** `Fragment` keeps the tree and its + indentation, `RawHtml` loses both. +6. **Boolean attributes are `True`**, not `"true"`. `False`/`None` omit the attribute. +7. **Names that collide take a trailing underscore**: `cls`, `for_`, `type_`, `id_`. + The rendered attribute keeps the HTML spelling. +8. **`Document` owns the doctype and `lang`.** Never write `<!DOCTYPE html>` by hand. +9. **`render(node, options)` is a function and returns the string.** It does not print. + Options are a value; there is no global. +10. **Iterables are flattened and `None` children are dropped** — that is how loops and + conditionals work: `Ul(Li(x) for x in items)`, `Div(x if cond else None)`. +11. **`Img` requires `alt`; `Iframe` requires `title`.** Deliberate. Do not work around it. +12. **Never invent an element name.** Check `references/tag-catalog.md`. + +## Read it when + +| File | Read it when | +| --- | --- | +| `references/tag-catalog.md` | You need an element name or its signature. **Generated from the source, so it cannot drift.** | +| `references/recipes.md` | You need the shape for a page, layout, component, table, form, media, SEO head, sitemap, feed, or writing to disk. | +| `references/pitfalls.md` | Output is wrong, something is double-escaped, or a 0.1.0 name is missing. | + +## Minimal example + +```python +from winged import Body, Document, H1, Head, P, Title, render + +page = Document(Head(Title("Home")), Body(H1("Hi"), P("<ok>")), lang="pt-BR") +print(render(page)) +# <!DOCTYPE html> +# <html lang="pt-BR"><head><title>Home

Hi

<ok>

+``` diff --git a/.claude/skills/winged/references/pitfalls.md b/.claude/skills/winged/references/pitfalls.md new file mode 120000 index 0000000..1db8a43 --- /dev/null +++ b/.claude/skills/winged/references/pitfalls.md @@ -0,0 +1 @@ +../../../../docs/pitfalls.md \ No newline at end of file diff --git a/.claude/skills/winged/references/recipes.md b/.claude/skills/winged/references/recipes.md new file mode 120000 index 0000000..ca3e0e0 --- /dev/null +++ b/.claude/skills/winged/references/recipes.md @@ -0,0 +1 @@ +../../../../docs/recipes.md \ No newline at end of file diff --git a/.claude/skills/winged/references/tag-catalog.md b/.claude/skills/winged/references/tag-catalog.md new file mode 120000 index 0000000..cd41d0d --- /dev/null +++ b/.claude/skills/winged/references/tag-catalog.md @@ -0,0 +1 @@ +../../../../docs/tag-catalog.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..84b2cda --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,157 @@ +# AGENTS.md — working with Winged-Python + +Instructions for anyone changing this repository, human or coding agent. + +## What Winged-Python is + +A dependency-free DSL that builds an HTML **string**. No DOM, no browser, no server. You +compose elements into a tree and render it to text. + +Three things follow from that, and most mistakes come from forgetting one of them: + +1. **The output is a string**, so tests assert on rendered strings — never on tree shape. +2. **Escaping is the whole security surface.** Text and attribute values are escaped by + default; `RawHtml` is the only opt-out and must never receive user input. +3. **The tree has reference semantics.** Putting one element in two parents shares the + node; it is not copied. + +```python +from winged import Body, Document, H1, Head, P, Title, render + +page = Document(Head(Title("Home")), Body(H1("Hi"), P("")), lang="pt-BR") +render(page) +# +# Home

Hi

<ok>

+``` + +## Commands + +| Task | Command | +| --- | --- | +| Everything, before you say you are done | `./scripts/verify.sh` | +| Tests | `pytest` | +| One test file | `pytest tests/test_element.py -q` | +| Lint and format | `ruff check . && ruff format .` | +| Types | `mypy` | +| Regenerate the elements | `python scripts/generate_elements.py` | +| Regenerate the tag catalogue | `python scripts/generate_tag_catalog.py` | +| Regenerate the golden fixtures | `WINGED_UPDATE_FIXTURES=1 pytest tests/test_golden.py` | + +## Repository map + +``` +src/winged/ + core/ + escape.py escape_text / escape_attribute / escape_xml — the security surface + attribute.py Attribute, and the boolean-attribute set + element.py Element: the tree, the chainable helpers, the pretty printer + node.py Text, RawHtml, Fragment, Comment, and child coercion + render.py RenderOptions, the Node protocol, render() + tags.py VOID_ELEMENTS and WHITESPACE_SENSITIVE + _tagtable.py the one declarative table every element is generated from + elements.py GENERATED — do not edit + _special.py Img and Iframe, whose signatures require an argument + document.py Document: doctype and + layout.py the Layout protocol + seo.py Open Graph, Twitter Cards, SeoBuilder + sitemap.py sitemap 0.9 + feed.py RSS 2.0 + ssg.py StaticSiteGenerator + accessibility.py the audit + cli.py winged new / build / serve + templates/ what `winged new` scaffolds +scripts/ the two generators and verify.sh +tests/fixtures/ Winged-Swift's golden files, copied +docs/ tag-catalog.md is generated; recipes and pitfalls are not +``` + +## Rules + +1. **Prefer the varargs form.** `Div(P("a"), cls="x")`, not `Div().child(P("a"))`. The + chainable helpers are for the cases where the value is computed. +2. **Never invent an element name.** Check `docs/tag-catalog.md`. If it is not there, add + a row to `_tagtable.py` and regenerate — see the contract below. +3. **Content and attributes are escaped for you.** Never pre-escape; you will get + `&lt;`. +4. **`RawHtml` is the only way in for markup**, and never for anything from data. +5. **Void elements take no children.** `Br`, `Img`, `Input`, `Link`, `Meta`, `Hr`, `Col`, + `Source`, `Track`, `Wbr`, `Base`, `Embed`. Passing one a child raises. +6. **`Fragment` groups, `RawHtml` injects.** `Fragment` keeps the tree and its + indentation; `RawHtml` flattens to a string and loses both. +7. **Boolean attributes are `True`**, not `"true"`. `False` and `None` omit the attribute. +8. **`Document` owns the doctype.** Never write `` by hand. +9. **Rendering is configured by value.** Pass `RenderOptions`; never add a global. +10. **Names that collide take a trailing underscore** — `cls`, `for_`, `type_`, `id_`. + The rendered attribute keeps the HTML spelling. +11. **`Img` requires `alt` and `Iframe` requires `title`.** That is deliberate; do not + add defaults. + +## Recipes + +### A full page written to disk + +```python +from winged import Body, Document, H1, Head, Link, Main, Title +from winged.seo import SeoBuilder +from winged.ssg import StaticSiteGenerator + +head = Head( + SeoBuilder(title="Home", description="…", image="…", url="…").build(), + Title("Home"), + Link(href="/css/style.css", rel="stylesheet"), +) +page = Document(head, Body(Main(H1("Home"))), lang="pt-BR") + +site = StaticSiteGenerator("dist") +site.clean() +site.generate(page, "index.html") +``` + +### A reusable component + +A component is a function returning a node. There is no base class to inherit. + +```python +from winged import A, Div, H3, P +from winged.core.render import Node + + +def card(title: str, body: str, href: str) -> Node: + return Div(H3(title), P(body), A("Read more", href=href), cls="card") +``` + +## Adding an element to the library + +1. Add a row to `src/winged/_tagtable.py`: `("Name", "tag", "Group")`. +2. If it is void, add the tag to `VOID_ELEMENTS` in `src/winged/core/tags.py`. If its + content is whitespace-sensitive, add it to `WHITESPACE_SENSITIVE`. +3. Run `python scripts/generate_elements.py` and + `python scripts/generate_tag_catalog.py`. Commit both generated files. +4. `tests/test_tag_catalog.py` picks the element up automatically — confirm it passes. +5. Add a `CHANGELOG.md` entry under `## [Unreleased]`. + +An element whose signature must require an argument (like `Img`) goes in `_special.py` +instead, and gets a row in the generator's `names` list. + +## Conventions + +- Four-space indent, 100-column lines, `ruff format`. +- A docstring on every public module, class and function. +- Comments explain **why**, not what. If a line encodes a decision, say what the + alternative was and why it lost. +- Tests are pytest: plain functions, `parametrize`, `capsys`. No `unittest.TestCase`. +- Assert on rendered strings, never on private attributes. +- Commit messages: `Add:`, `Fix:`, `Change:`, `Docs:`. +- English, in code and comments — the Swift sibling is in English too. + +## Before you say you are done + +```bash +./scripts/verify.sh +``` + +It builds, tests, lints, type-checks, verifies both generated files are current, checks +byte-for-byte parity against Winged-Swift's fixtures, installs the package into a +throwaway venv and renders a page with it, then runs `winged new` and `winged build` +**from `/`** — because a generator that resolves paths from the current directory passes +every unit test and still fails for the user. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9059693 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,96 @@ +# Changelog + +All notable changes to Winged-Python are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-09-18 + +Parity with [Winged-Swift](https://github.com/micheltlutz/Winged-Swift) 2.0.0, demonstrated +rather than claimed: `tests/test_golden.py` reproduces all four of Winged-Swift's golden +fixtures byte for byte. + +**This release is a clean break from 0.1.0.** Nothing from the old API survives. See +[MIGRATION.md](MIGRATION.md) for the replacement of every removed name, and +[PORTING.md](PORTING.md) for the map from Winged-Swift. + +### Added + +- **HTML escaping** — `escape_text`, `escape_attribute` and `escape_xml`. 0.1.0 escaped + nothing at all: `String.get_string()` returned its text verbatim and attribute values + were interpolated straight into `key="value"`. Text children are escaped by default and + `RawHtml` is the only opt-out. +- **93 elements**, up from 49, generated from one table in `winged/_tagtable.py`. + `docs/tag-catalog.md` is generated from the same table. +- **`RenderOptions`** — `pretty`, `indent`, `xhtml_self_closing`, passed per call rather + than held as global state. 0.1.0 had no pretty printing; its README showed indented + output the library could not produce. +- **`Document`** — owns `` and ``. +- **`Fragment`, `RawHtml`, `Comment`, `Text`** — a transparent group, an explicit raw + injection, a comment that refuses `--`, and escaped text. +- **The varargs API** — `Div(H1("x"), P("y"), cls="box")`. Iterables are flattened, so + `Ul(Li(x) for x in items)` works; `None` children are dropped, so + `Div(x if cond else None)` is the conditional form. +- **Chainable helpers** — `add_class`, `add_classes`, `set_id`, `set_style`, `set_role`, + `attr`, `data_attr(s)`, `aria_attr(s)`, each returning `Self`. +- **`seo`** — Open Graph, Open Graph Article, Twitter Cards, the common head set, and + `SeoBuilder`. +- **`sitemap`** and **`feed`** — sitemap 0.9 and RSS 2.0 generators. +- **`ssg.StaticSiteGenerator`** — writes pages and assets. `clean()` refuses the + filesystem root, the home directory, and anything reached through a symlink out of the + output directory. +- **`accessibility.audit`** — eight rules (`img-alt`, `button-label`, `iframe-title`, + `link-text`, `heading-order`, `html-lang`, `form-label`, `duplicate-id`). This is the + check Winged-Swift's `ROADMAP.md` asks for and has not shipped. +- **The `winged` CLI** — `winged new`, `winged build`, `winged serve [--watch]`. + Stdlib only; the dev server refuses to serve outside its root and binds `127.0.0.1`. +- **`Layout`** — a `typing.Protocol`, satisfied by defining `render` with no base class. +- **Typing** — the package ships `py.typed` and passes `mypy --strict`. +- **`pyproject.toml`**, a `src/` layout, ruff, a five-version CI matrix that runs on pull + requests, `scripts/verify.sh`, and release to PyPI via trusted publishing. +- **Docs** — `GETTING_STARTED.md`, `MIGRATION.md`, `PORTING.md`, `ROADMAP.md`, + `AGENTS.md`, `docs/tag-catalog.md`, `docs/recipes.md`, `docs/pitfalls.md`. + +### Changed + +- **Rendering writes into one buffer.** `write_into(buf, options, depth)` is the single + overridable primitive. 0.1.0's `get_string()` built a new string per node, which is + quadratic; `tests/test_performance.py` guards against a return to it. +- **`render()` returns the markup.** 0.1.0's `generate()` printed it and returned `None`, + so `print(x.generate())` printed the page and then printed `None`. +- **Void elements refuse children** instead of silently discarding them, and render + without a closing tag. 0.1.0 emitted `` and ``. +- **`
`, `` and `