webwidgets 0.1.0__tar.gz
Sign up to get free protection for your applications and to get access to all the features.
- webwidgets-0.1.0/.github/workflows/cd.yml +123 -0
- webwidgets-0.1.0/.github/workflows/ci-full.yml +80 -0
- webwidgets-0.1.0/.github/workflows/ci-quick.yml +78 -0
- webwidgets-0.1.0/.gitignore +1 -0
- webwidgets-0.1.0/LICENSE +21 -0
- webwidgets-0.1.0/PKG-INFO +18 -0
- webwidgets-0.1.0/README.md +5 -0
- webwidgets-0.1.0/pyproject.toml +28 -0
- webwidgets-0.1.0/tests/__init__.py +11 -0
- webwidgets-0.1.0/tests/compilation/__init__.py +11 -0
- webwidgets-0.1.0/tests/compilation/test_html_node.py +334 -0
- webwidgets-0.1.0/tests/utility/__init__.py +11 -0
- webwidgets-0.1.0/tests/utility/test_sanitizing.py +105 -0
- webwidgets-0.1.0/webwidgets/__init__.py +15 -0
- webwidgets-0.1.0/webwidgets/compilation/__init__.py +13 -0
- webwidgets-0.1.0/webwidgets/compilation/html/__init__.py +13 -0
- webwidgets-0.1.0/webwidgets/compilation/html/html_node.py +210 -0
- webwidgets-0.1.0/webwidgets/utility/__init__.py +13 -0
- webwidgets-0.1.0/webwidgets/utility/sanitizing.py +132 -0
@@ -0,0 +1,123 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
12
|
+
|
13
|
+
name: "CD: Publish to PyPI (or TestPyPI)"
|
14
|
+
|
15
|
+
on:
|
16
|
+
push:
|
17
|
+
tags:
|
18
|
+
- "[0-9]+.[0-9]+.[0-9]+"
|
19
|
+
- "[0-9]+.[0-9]+.[0-9]+.dev[0-9]+"
|
20
|
+
- "[0-9]+.[0-9]+.[0-9]+[ab][0-9]+"
|
21
|
+
- "[0-9]+.[0-9]+.[0-9]+[ab][0-9]+.dev[0-9]+"
|
22
|
+
- "[0-9]+.[0-9]+.[0-9].dev[0-9]+"
|
23
|
+
- "[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
|
24
|
+
- "[0-9]+.[0-9]+.[0-9]+rc[0-9]+.dev[0-9]+"
|
25
|
+
- "[0-9]+.[0-9]+.[0-9]+post[0-9]+"
|
26
|
+
- "[0-9]+.[0-9]+.[0-9]+post[0-9]+.dev[0-9]+"
|
27
|
+
- "testpypi/[0-9]+.[0-9]+.[0-9]+"
|
28
|
+
- "testpypi/[0-9]+.[0-9]+.[0-9]+.dev[0-9]+"
|
29
|
+
- "testpypi/[0-9]+.[0-9]+.[0-9]+[ab][0-9]+"
|
30
|
+
- "testpypi/[0-9]+.[0-9]+.[0-9]+[ab][0-9]+.dev[0-9]+"
|
31
|
+
- "testpypi/[0-9]+.[0-9]+.[0-9].dev[0-9]+"
|
32
|
+
- "testpypi/[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
|
33
|
+
- "testpypi/[0-9]+.[0-9]+.[0-9]+rc[0-9]+.dev[0-9]+"
|
34
|
+
- "testpypi/[0-9]+.[0-9]+.[0-9]+post[0-9]+"
|
35
|
+
- "testpypi/[0-9]+.[0-9]+.[0-9]+post[0-9]+.dev[0-9]+"
|
36
|
+
|
37
|
+
jobs:
|
38
|
+
ensure-main:
|
39
|
+
if: github.event.base_ref == 'refs/heads/main'
|
40
|
+
name: Ensure tag was pushed to main
|
41
|
+
runs-on: ubuntu-latest
|
42
|
+
steps:
|
43
|
+
- name: Log that tag was pushed to main
|
44
|
+
run: echo "Tag was pushed to main branch. Starting CD workflow."
|
45
|
+
|
46
|
+
build:
|
47
|
+
name: Build package
|
48
|
+
needs: ensure-main
|
49
|
+
runs-on: ubuntu-latest
|
50
|
+
steps:
|
51
|
+
- uses: actions/checkout@v4
|
52
|
+
with:
|
53
|
+
persist-credentials: false
|
54
|
+
- name: Set up Python
|
55
|
+
uses: actions/setup-python@v5
|
56
|
+
with:
|
57
|
+
python-version: "3.x"
|
58
|
+
- name: Install pypa/build
|
59
|
+
run: >-
|
60
|
+
python3 -m
|
61
|
+
pip install
|
62
|
+
build
|
63
|
+
--user
|
64
|
+
- name: Install pypa/hatch
|
65
|
+
run: python3 -m pip install hatch
|
66
|
+
- name: Set version with hatch
|
67
|
+
run: |
|
68
|
+
# Using variable instead of GitHub-specific contexts
|
69
|
+
TAG=$(git describe --tags --abbrev=0)
|
70
|
+
echo "Tag is: $TAG"
|
71
|
+
# Removing testpypi/ from tag before setting version
|
72
|
+
VERSION=$(echo $TAG | awk '{gsub(/testpypi\//,"")}1')
|
73
|
+
echo "Setting version from tag: $VERSION"
|
74
|
+
hatch version $VERSION
|
75
|
+
- name: Build a binary wheel and a source tarball
|
76
|
+
run: python3 -m build
|
77
|
+
- name: Store the distribution packages
|
78
|
+
uses: actions/upload-artifact@v4
|
79
|
+
with:
|
80
|
+
name: python-package-distributions
|
81
|
+
path: dist/
|
82
|
+
|
83
|
+
publish-to-pypi:
|
84
|
+
if: ${{ !startsWith(github.ref_name, 'testpypi/') }}
|
85
|
+
name: Publish to PyPI
|
86
|
+
needs:
|
87
|
+
- build
|
88
|
+
runs-on: ubuntu-latest
|
89
|
+
environment:
|
90
|
+
name: pypi
|
91
|
+
url: https://pypi.org/p/webwidgets
|
92
|
+
permissions:
|
93
|
+
id-token: write # IMPORTANT: mandatory for trusted publishing
|
94
|
+
steps:
|
95
|
+
- name: Download all the dists
|
96
|
+
uses: actions/download-artifact@v4
|
97
|
+
with:
|
98
|
+
name: python-package-distributions
|
99
|
+
path: dist/
|
100
|
+
- name: Publish package to PyPI
|
101
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
102
|
+
|
103
|
+
publish-to-testpypi:
|
104
|
+
if: startsWith(github.ref_name, 'testpypi/')
|
105
|
+
name: Publish to TestPyPI
|
106
|
+
needs:
|
107
|
+
- build
|
108
|
+
runs-on: ubuntu-latest
|
109
|
+
environment:
|
110
|
+
name: testpypi
|
111
|
+
url: https://test.pypi.org/p/webwidgets
|
112
|
+
permissions:
|
113
|
+
id-token: write # IMPORTANT: mandatory for trusted publishing
|
114
|
+
steps:
|
115
|
+
- name: Download all the dists
|
116
|
+
uses: actions/download-artifact@v4
|
117
|
+
with:
|
118
|
+
name: python-package-distributions
|
119
|
+
path: dist/
|
120
|
+
- name: Publish package to TestPyPI
|
121
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
122
|
+
with:
|
123
|
+
repository-url: https://test.pypi.org/legacy/
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
12
|
+
|
13
|
+
name: "Full CI: Python 3.9-13 on all OSes"
|
14
|
+
|
15
|
+
on:
|
16
|
+
push:
|
17
|
+
branches: main
|
18
|
+
pull_request:
|
19
|
+
branches: main
|
20
|
+
|
21
|
+
permissions:
|
22
|
+
contents: read
|
23
|
+
|
24
|
+
jobs:
|
25
|
+
lint_source:
|
26
|
+
strategy:
|
27
|
+
matrix:
|
28
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
29
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
30
|
+
name: Lint source on ${{ matrix.os }} Python ${{ matrix.python-version }}
|
31
|
+
runs-on: ${{ matrix.os }}
|
32
|
+
steps:
|
33
|
+
- name: Checkout repository
|
34
|
+
uses: actions/checkout@v4
|
35
|
+
- name: Set up Python ${{ matrix.python-version }}
|
36
|
+
uses: actions/setup-python@v3
|
37
|
+
with:
|
38
|
+
python-version: ${{ matrix.python-version }}
|
39
|
+
- name: Install flake8
|
40
|
+
run: |
|
41
|
+
python -c "import platform; print('OS', platform.system())"
|
42
|
+
python -c "import sys; print('Python version', sys.version)"
|
43
|
+
python -m pip install --upgrade pip
|
44
|
+
pip install flake8
|
45
|
+
- name: Lint with flake8
|
46
|
+
run: |
|
47
|
+
# stop the build if there are Python syntax errors or undefined names
|
48
|
+
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
49
|
+
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
50
|
+
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
51
|
+
|
52
|
+
test_build:
|
53
|
+
needs: lint_source
|
54
|
+
strategy:
|
55
|
+
matrix:
|
56
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
57
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
58
|
+
name: Test build on ${{ matrix.os }} Python ${{ matrix.python-version }}
|
59
|
+
runs-on: ${{ matrix.os }}
|
60
|
+
steps:
|
61
|
+
- name: Checkout repository
|
62
|
+
uses: actions/checkout@v4
|
63
|
+
- name: Set up Python ${{ matrix.python-version }}
|
64
|
+
uses: actions/setup-python@v3
|
65
|
+
with:
|
66
|
+
python-version: ${{ matrix.python-version }}
|
67
|
+
- name: Install pytest
|
68
|
+
run: |
|
69
|
+
python -c "import platform; print('OS', platform.system())"
|
70
|
+
python -c "import sys; print('Python version', sys.version)"
|
71
|
+
python -m pip install --upgrade pip
|
72
|
+
pip install pytest
|
73
|
+
- name: Build and install
|
74
|
+
run: |
|
75
|
+
pip install .
|
76
|
+
# Removing webwidgets directory so imports come from build
|
77
|
+
rm -r webwidgets
|
78
|
+
- name: Test with pytest
|
79
|
+
run: |
|
80
|
+
pytest tests
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
12
|
+
|
13
|
+
name: "Quick CI: Python 3.9-11 on Ubuntu"
|
14
|
+
|
15
|
+
on:
|
16
|
+
push:
|
17
|
+
branches: '*'
|
18
|
+
|
19
|
+
permissions:
|
20
|
+
contents: read
|
21
|
+
|
22
|
+
jobs:
|
23
|
+
lint_source:
|
24
|
+
strategy:
|
25
|
+
matrix:
|
26
|
+
python-version: ["3.9", "3.10", "3.11"]
|
27
|
+
name: Lint source on Python ${{ matrix.python-version }}
|
28
|
+
runs-on: ubuntu-latest
|
29
|
+
steps:
|
30
|
+
- name: Checkout repository
|
31
|
+
uses: actions/checkout@v4
|
32
|
+
- name: Set up Python ${{ matrix.python-version }}
|
33
|
+
uses: actions/setup-python@v3
|
34
|
+
with:
|
35
|
+
python-version: ${{ matrix.python-version }}
|
36
|
+
- name: Install flake8
|
37
|
+
run: |
|
38
|
+
python -c "import sys; print('Python version', sys.version)"
|
39
|
+
python -m pip install --upgrade pip
|
40
|
+
pip install flake8
|
41
|
+
- name: Lint with flake8
|
42
|
+
run: |
|
43
|
+
# stop the build if there are Python syntax errors or undefined names
|
44
|
+
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
45
|
+
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
46
|
+
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
47
|
+
|
48
|
+
test_build:
|
49
|
+
needs: lint_source
|
50
|
+
strategy:
|
51
|
+
matrix:
|
52
|
+
python-version: ["3.9", "3.10", "3.11"]
|
53
|
+
name: Test build on Python ${{ matrix.python-version }}
|
54
|
+
runs-on: ubuntu-latest
|
55
|
+
steps:
|
56
|
+
- name: Checkout repository
|
57
|
+
uses: actions/checkout@v4
|
58
|
+
- name: Set up Python ${{ matrix.python-version }}
|
59
|
+
uses: actions/setup-python@v3
|
60
|
+
with:
|
61
|
+
python-version: ${{ matrix.python-version }}
|
62
|
+
- name: Install pytest
|
63
|
+
run: |
|
64
|
+
python -c "import sys; print('Python version', sys.version)"
|
65
|
+
python -m pip install --upgrade pip
|
66
|
+
pip install pytest
|
67
|
+
- name: Build and install
|
68
|
+
run: |
|
69
|
+
echo "Current directory:"
|
70
|
+
ls -la
|
71
|
+
pip install .
|
72
|
+
# Removing webwidgets directory so imports come from build
|
73
|
+
rm -r webwidgets
|
74
|
+
echo "Removed webwidgets directory. New content:"
|
75
|
+
ls -la
|
76
|
+
- name: Test with pytest
|
77
|
+
run: |
|
78
|
+
pytest tests
|
@@ -0,0 +1 @@
|
|
1
|
+
__pycache__
|
webwidgets-0.1.0/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 mlaasri
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -0,0 +1,18 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: webwidgets
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: A Python package for designing web UIs.
|
5
|
+
Project-URL: Source code, https://github.com/mlaasri/WebWidgets
|
6
|
+
Author: mlaasri
|
7
|
+
License-File: LICENSE
|
8
|
+
Keywords: design,webui
|
9
|
+
Classifier: Operating System :: OS Independent
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
11
|
+
Requires-Python: >=3.9
|
12
|
+
Description-Content-Type: text/markdown
|
13
|
+
|
14
|
+
# WebWidgets
|
15
|
+
|
16
|
+

|
17
|
+
|
18
|
+
A Python package for creating web UIs
|
@@ -0,0 +1,28 @@
|
|
1
|
+
[build-system]
|
2
|
+
requires = ["hatchling"]
|
3
|
+
build-backend = "hatchling.build"
|
4
|
+
|
5
|
+
[project]
|
6
|
+
name = "webwidgets"
|
7
|
+
dynamic = ["version"]
|
8
|
+
description = "A Python package for designing web UIs."
|
9
|
+
readme = "README.md"
|
10
|
+
requires-python = ">=3.9"
|
11
|
+
license-files = { paths = ["LICENSE"] }
|
12
|
+
authors = [
|
13
|
+
{ name="mlaasri" }
|
14
|
+
]
|
15
|
+
keywords = ["webui", "design"]
|
16
|
+
classifiers = [
|
17
|
+
"Programming Language :: Python :: 3",
|
18
|
+
"Operating System :: OS Independent",
|
19
|
+
]
|
20
|
+
|
21
|
+
[project.urls]
|
22
|
+
"Source code" = "https://github.com/mlaasri/WebWidgets"
|
23
|
+
|
24
|
+
[tool.hatch.version]
|
25
|
+
path = "webwidgets/__init__.py"
|
26
|
+
|
27
|
+
[tool.hatch.build]
|
28
|
+
directory = "dist"
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
@@ -0,0 +1,334 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
12
|
+
|
13
|
+
import pytest
|
14
|
+
from webwidgets.compilation.html.html_node import HTMLNode, no_start_tag, no_end_tag, RawText
|
15
|
+
|
16
|
+
|
17
|
+
class TestHTMLNode:
|
18
|
+
class CustomNode(HTMLNode):
|
19
|
+
pass
|
20
|
+
|
21
|
+
@no_start_tag
|
22
|
+
class NoStartNode(HTMLNode):
|
23
|
+
pass
|
24
|
+
|
25
|
+
@no_end_tag
|
26
|
+
class NoEndNode(HTMLNode):
|
27
|
+
pass
|
28
|
+
|
29
|
+
@no_start_tag
|
30
|
+
@no_end_tag
|
31
|
+
class NoStartEndNode(HTMLNode):
|
32
|
+
pass
|
33
|
+
|
34
|
+
class OneLineNode(HTMLNode):
|
35
|
+
one_line = True
|
36
|
+
|
37
|
+
class OneLineNoStartNode(NoStartNode):
|
38
|
+
one_line = True
|
39
|
+
|
40
|
+
class KwargsReceiverNode(HTMLNode):
|
41
|
+
def to_html(self, return_lines: bool, message: str,
|
42
|
+
**kwargs):
|
43
|
+
if return_lines:
|
44
|
+
return [message]
|
45
|
+
return message
|
46
|
+
|
47
|
+
def test_basic_node(self):
|
48
|
+
node = HTMLNode()
|
49
|
+
assert node.start_tag == "<htmlnode>"
|
50
|
+
assert node.end_tag == "</htmlnode>"
|
51
|
+
assert node.to_html() == "<htmlnode></htmlnode>"
|
52
|
+
|
53
|
+
def test_custom_name(self):
|
54
|
+
node = TestHTMLNode.CustomNode()
|
55
|
+
assert node.start_tag == "<customnode>"
|
56
|
+
assert node.end_tag == "</customnode>"
|
57
|
+
assert node.to_html() == "<customnode></customnode>"
|
58
|
+
|
59
|
+
def test_attributes(self):
|
60
|
+
node = HTMLNode(attributes={'id': 'test-id', 'class': 'test-class'})
|
61
|
+
assert node.start_tag == '<htmlnode id="test-id" class="test-class">'
|
62
|
+
assert node.end_tag == '</htmlnode>'
|
63
|
+
assert node.to_html() == '<htmlnode id="test-id" class="test-class"></htmlnode>'
|
64
|
+
|
65
|
+
def test_no_start_tag(self):
|
66
|
+
node = TestHTMLNode.NoStartNode()
|
67
|
+
assert node.start_tag == ''
|
68
|
+
assert node.end_tag == '</nostartnode>'
|
69
|
+
assert node.to_html() == "</nostartnode>"
|
70
|
+
|
71
|
+
def test_no_end_tag(self):
|
72
|
+
node = TestHTMLNode.NoEndNode()
|
73
|
+
assert node.start_tag == '<noendnode>'
|
74
|
+
assert node.end_tag == ''
|
75
|
+
assert node.to_html() == "<noendnode>"
|
76
|
+
|
77
|
+
def test_no_start_end_tag(self):
|
78
|
+
node = TestHTMLNode.NoStartEndNode()
|
79
|
+
assert node.start_tag == ''
|
80
|
+
assert node.end_tag == ''
|
81
|
+
assert node.to_html() == ""
|
82
|
+
|
83
|
+
def test_one_line_rendering(self):
|
84
|
+
node = HTMLNode(children=[RawText('child1'),
|
85
|
+
RawText('child2')])
|
86
|
+
expected_html = "<htmlnode>child1child2</htmlnode>"
|
87
|
+
assert node.to_html(force_one_line=True) == expected_html
|
88
|
+
|
89
|
+
def test_no_start_tag_with_one_line(self):
|
90
|
+
node = TestHTMLNode.NoStartNode(children=[RawText('child1'),
|
91
|
+
RawText('child2')])
|
92
|
+
expected_html = "child1child2</nostartnode>"
|
93
|
+
assert node.to_html(force_one_line=True) == expected_html
|
94
|
+
|
95
|
+
def test_no_end_tag_with_one_line(self):
|
96
|
+
node = TestHTMLNode.NoEndNode(children=[RawText('child1'),
|
97
|
+
RawText('child2')])
|
98
|
+
expected_html = "<noendnode>child1child2"
|
99
|
+
assert node.to_html(force_one_line=True) == expected_html
|
100
|
+
|
101
|
+
def test_recursive_rendering(self):
|
102
|
+
inner_node = HTMLNode(children=[RawText('inner_child')])
|
103
|
+
node = TestHTMLNode.CustomNode(children=[inner_node])
|
104
|
+
expected_html = '\n'.join([
|
105
|
+
"<customnode>",
|
106
|
+
" <htmlnode>",
|
107
|
+
" inner_child",
|
108
|
+
" </htmlnode>",
|
109
|
+
"</customnode>"
|
110
|
+
])
|
111
|
+
assert node.to_html() == expected_html
|
112
|
+
assert node.to_html(force_one_line=False) == expected_html
|
113
|
+
|
114
|
+
def test_no_start_tag_with_recursive_rendering(self):
|
115
|
+
inner_node = HTMLNode(children=[RawText('inner_child')])
|
116
|
+
node = TestHTMLNode.NoStartNode(children=[inner_node])
|
117
|
+
expected_html = '\n'.join([
|
118
|
+
" <htmlnode>",
|
119
|
+
" inner_child",
|
120
|
+
" </htmlnode>",
|
121
|
+
"</nostartnode>"
|
122
|
+
])
|
123
|
+
assert node.to_html() == expected_html
|
124
|
+
|
125
|
+
def test_no_end_tag_with_recursive_rendering(self):
|
126
|
+
inner_node = HTMLNode(children=[RawText('inner_child')])
|
127
|
+
node = TestHTMLNode.NoEndNode(children=[inner_node])
|
128
|
+
expected_html = '\n'.join([
|
129
|
+
"<noendnode>",
|
130
|
+
" <htmlnode>",
|
131
|
+
" inner_child",
|
132
|
+
" </htmlnode>"
|
133
|
+
])
|
134
|
+
assert node.to_html() == expected_html
|
135
|
+
|
136
|
+
def test_recursive_rendering_one_line(self):
|
137
|
+
inner_node = HTMLNode(children=[RawText('inner_child')])
|
138
|
+
node = TestHTMLNode.CustomNode(children=[inner_node])
|
139
|
+
expected_html = "<customnode><htmlnode>inner_child</htmlnode></customnode>"
|
140
|
+
assert node.to_html(force_one_line=True) == expected_html
|
141
|
+
|
142
|
+
def test_recursive_rendering_one_line_propagation(self):
|
143
|
+
one_line = TestHTMLNode.OneLineNode(
|
144
|
+
[HTMLNode(children=[RawText('inner_child')])]
|
145
|
+
)
|
146
|
+
node = HTMLNode(children=[one_line])
|
147
|
+
expected_html = '\n'.join([
|
148
|
+
"<htmlnode>",
|
149
|
+
" <onelinenode><htmlnode>inner_child</htmlnode></onelinenode>",
|
150
|
+
"</htmlnode>"
|
151
|
+
])
|
152
|
+
assert node.to_html() == expected_html
|
153
|
+
|
154
|
+
def test_recursive_rendering_of_tagless_mix(self):
|
155
|
+
children = [
|
156
|
+
TestHTMLNode.NoEndNode([RawText("child1")]),
|
157
|
+
TestHTMLNode.NoStartNode([RawText("child2")]),
|
158
|
+
TestHTMLNode.NoEndNode([RawText("child3")]),
|
159
|
+
]
|
160
|
+
inner_node = TestHTMLNode.NoStartNode(children=children)
|
161
|
+
node = TestHTMLNode.NoEndNode(children=[inner_node])
|
162
|
+
expected_html = '\n'.join([
|
163
|
+
"<noendnode>",
|
164
|
+
" <noendnode>",
|
165
|
+
" child1",
|
166
|
+
" child2",
|
167
|
+
" </nostartnode>",
|
168
|
+
" <noendnode>",
|
169
|
+
" child3",
|
170
|
+
" </nostartnode>"
|
171
|
+
])
|
172
|
+
assert node.to_html() == expected_html
|
173
|
+
|
174
|
+
def test_recursive_rendering_of_tagless_mix_one_line(self):
|
175
|
+
children = [
|
176
|
+
TestHTMLNode.NoEndNode([RawText("child1")]),
|
177
|
+
TestHTMLNode.OneLineNoStartNode([RawText("child2")]),
|
178
|
+
TestHTMLNode.NoEndNode([RawText("child3")]),
|
179
|
+
]
|
180
|
+
inner_node = TestHTMLNode.NoStartNode(children=children)
|
181
|
+
node = TestHTMLNode.NoEndNode(children=[inner_node])
|
182
|
+
expected_html = '\n'.join([
|
183
|
+
"<noendnode>",
|
184
|
+
" <noendnode>",
|
185
|
+
" child1",
|
186
|
+
" child2</onelinenostartnode>",
|
187
|
+
" <noendnode>",
|
188
|
+
" child3",
|
189
|
+
" </nostartnode>"
|
190
|
+
])
|
191
|
+
assert node.to_html() == expected_html
|
192
|
+
|
193
|
+
def test_recursive_rendering_of_tagless_mix_force_one_line(self):
|
194
|
+
children = [
|
195
|
+
TestHTMLNode.NoEndNode([RawText("child1")]),
|
196
|
+
TestHTMLNode.NoStartNode([RawText("child2")]),
|
197
|
+
TestHTMLNode.NoEndNode([RawText("child3")]),
|
198
|
+
]
|
199
|
+
inner_node = TestHTMLNode.NoStartNode(children=children)
|
200
|
+
node = TestHTMLNode.NoEndNode(children=[inner_node])
|
201
|
+
expected_html = "<noendnode><noendnode>child1child2</nostartnode>" + \
|
202
|
+
"<noendnode>child3</nostartnode>"
|
203
|
+
assert node.to_html(force_one_line=True) == expected_html
|
204
|
+
|
205
|
+
def test_raw_text_as_orphan_node(self):
|
206
|
+
node = HTMLNode(children=[
|
207
|
+
TestHTMLNode.CustomNode(),
|
208
|
+
RawText("raw_text")
|
209
|
+
])
|
210
|
+
expected_html = '\n'.join([
|
211
|
+
"<htmlnode>",
|
212
|
+
" <customnode></customnode>",
|
213
|
+
" raw_text",
|
214
|
+
"</htmlnode>"
|
215
|
+
])
|
216
|
+
assert node.to_html() == expected_html
|
217
|
+
|
218
|
+
@pytest.mark.parametrize("indent_level", [0, 1, 2])
|
219
|
+
@pytest.mark.parametrize("indent_size", [3, 4, 8])
|
220
|
+
def test_indentation(self, indent_level: int, indent_size: int):
|
221
|
+
"""Test the to_html method with different indentation parameters."""
|
222
|
+
|
223
|
+
# Creating a simple HTMLNode
|
224
|
+
node = HTMLNode(children=[
|
225
|
+
RawText('child1'),
|
226
|
+
RawText('child2'),
|
227
|
+
HTMLNode(children=[
|
228
|
+
RawText('grandchild1'),
|
229
|
+
RawText('grandchild2')
|
230
|
+
])
|
231
|
+
])
|
232
|
+
|
233
|
+
# Expected output based on the test parameters
|
234
|
+
expected_html = "\n".join([
|
235
|
+
f"{' ' * indent_size * indent_level}<htmlnode>",
|
236
|
+
f"{' ' * indent_size * (indent_level + 1)}child1",
|
237
|
+
f"{' ' * indent_size * (indent_level + 1)}child2",
|
238
|
+
f"{' ' * indent_size * (indent_level + 1)}<htmlnode>",
|
239
|
+
f"{' ' * indent_size * (indent_level + 2)}grandchild1",
|
240
|
+
f"{' ' * indent_size * (indent_level + 2)}grandchild2",
|
241
|
+
f"{' ' * indent_size * (indent_level + 1)}</htmlnode>",
|
242
|
+
f"{' ' * indent_size * indent_level}</htmlnode>"
|
243
|
+
])
|
244
|
+
|
245
|
+
# Calling to_html with the test parameters
|
246
|
+
actual_html = node.to_html(
|
247
|
+
indent_size=indent_size, indent_level=indent_level)
|
248
|
+
assert actual_html == expected_html
|
249
|
+
|
250
|
+
@pytest.mark.parametrize("indent_level", [0, 1, 2])
|
251
|
+
@pytest.mark.parametrize("indent_size", [3, 4, 8])
|
252
|
+
def test_indentation_empty_node(self, indent_level, indent_size):
|
253
|
+
node = HTMLNode()
|
254
|
+
expected_html = f"{' ' * indent_size * indent_level}<htmlnode></htmlnode>"
|
255
|
+
actual_html = node.to_html(
|
256
|
+
indent_size=indent_size, indent_level=indent_level)
|
257
|
+
assert actual_html == expected_html
|
258
|
+
|
259
|
+
def test_collapse_empty(self):
|
260
|
+
node = HTMLNode(children=[
|
261
|
+
TestHTMLNode.CustomNode(),
|
262
|
+
HTMLNode(children=[RawText('grandchild1')])
|
263
|
+
])
|
264
|
+
expected_html = "\n".join([
|
265
|
+
"<htmlnode>",
|
266
|
+
" <customnode></customnode>",
|
267
|
+
" <htmlnode>",
|
268
|
+
" grandchild1",
|
269
|
+
" </htmlnode>",
|
270
|
+
"</htmlnode>"
|
271
|
+
])
|
272
|
+
assert node.to_html() == expected_html
|
273
|
+
assert node.to_html(collapse_empty=True) == expected_html
|
274
|
+
|
275
|
+
def test_not_collapse_empty(self):
|
276
|
+
node = HTMLNode(children=[
|
277
|
+
TestHTMLNode.CustomNode(),
|
278
|
+
HTMLNode(children=[RawText('grandchild1')])
|
279
|
+
])
|
280
|
+
expected_html = "\n".join([
|
281
|
+
"<htmlnode>",
|
282
|
+
" <customnode>",
|
283
|
+
" </customnode>",
|
284
|
+
" <htmlnode>",
|
285
|
+
" grandchild1",
|
286
|
+
" </htmlnode>",
|
287
|
+
"</htmlnode>"
|
288
|
+
])
|
289
|
+
assert node.to_html(collapse_empty=False) == expected_html
|
290
|
+
|
291
|
+
def test_kwargs_pass_down(self):
|
292
|
+
node = HTMLNode(children=[
|
293
|
+
TestHTMLNode.CustomNode(),
|
294
|
+
TestHTMLNode.KwargsReceiverNode()
|
295
|
+
])
|
296
|
+
expected_html = "\n".join([
|
297
|
+
"<htmlnode>",
|
298
|
+
" <customnode></customnode>",
|
299
|
+
"Message is 42",
|
300
|
+
"</htmlnode>"
|
301
|
+
])
|
302
|
+
assert node.to_html(message="Message is 42") == expected_html
|
303
|
+
|
304
|
+
@pytest.mark.parametrize("raw, sanitized", [
|
305
|
+
('<div>text</div>', "<div>text</div>"),
|
306
|
+
('\"Yes?\" > \'No!\'', ""Yes?" > 'No!'"),
|
307
|
+
('Yes &\nNo', "Yes &<br>No"),
|
308
|
+
])
|
309
|
+
def test_sanitize_raw_text(self, raw, sanitized):
|
310
|
+
node = HTMLNode(children=[RawText(raw)])
|
311
|
+
expected_html = "\n".join([
|
312
|
+
"<htmlnode>",
|
313
|
+
f" {sanitized}",
|
314
|
+
"</htmlnode>"
|
315
|
+
])
|
316
|
+
assert node.to_html() == expected_html
|
317
|
+
assert node.to_html(replace_all_entities=False) == expected_html
|
318
|
+
|
319
|
+
@pytest.mark.parametrize("raw, sanitized", [
|
320
|
+
('<div>text</div>',
|
321
|
+
"<div>text</div>"),
|
322
|
+
('\"Yes?\" > \'No!\'',
|
323
|
+
""Yes?" > 'No!'"),
|
324
|
+
('Yes &\nNo',
|
325
|
+
"Yes &<br>No"),
|
326
|
+
])
|
327
|
+
def test_sanitize_all_entities_in_raw_text(self, raw, sanitized):
|
328
|
+
node = HTMLNode(children=[RawText(raw)])
|
329
|
+
expected_html = "\n".join([
|
330
|
+
"<htmlnode>",
|
331
|
+
f" {sanitized}",
|
332
|
+
"</htmlnode>"
|
333
|
+
])
|
334
|
+
assert node.to_html(replace_all_entities=True) == expected_html
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
@@ -0,0 +1,105 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
12
|
+
|
13
|
+
import pytest
|
14
|
+
from webwidgets.utility.sanitizing import HTML_ENTITIES, \
|
15
|
+
CHAR_TO_HTML_ENTITIES, sanitize_html_text
|
16
|
+
|
17
|
+
|
18
|
+
class TestSanitizingHTMLText:
|
19
|
+
def test_no_empty_html_entities(self):
|
20
|
+
assert all(e for _, e in CHAR_TO_HTML_ENTITIES.items())
|
21
|
+
|
22
|
+
@pytest.mark.parametrize("name", [
|
23
|
+
'amp;', 'lt;', 'gt;', 'semi;', 'sol;', 'apos;', 'quot;'
|
24
|
+
])
|
25
|
+
def test_html_entity_names(self, name):
|
26
|
+
assert name in HTML_ENTITIES
|
27
|
+
|
28
|
+
def test_html_entities_inverted(self):
|
29
|
+
assert set(CHAR_TO_HTML_ENTITIES['&']) == set((
|
30
|
+
'amp;', 'AMP', 'amp', 'AMP;'))
|
31
|
+
assert CHAR_TO_HTML_ENTITIES['&'][0] == 'amp;'
|
32
|
+
assert set(CHAR_TO_HTML_ENTITIES['>']) == set((
|
33
|
+
'gt;', 'GT', 'gt', 'GT;'))
|
34
|
+
assert CHAR_TO_HTML_ENTITIES['>'][0] == 'gt;'
|
35
|
+
assert CHAR_TO_HTML_ENTITIES['\u0391'] == ('Alpha;',)
|
36
|
+
|
37
|
+
@pytest.mark.parametrize("html_entity", [
|
38
|
+
'&', '<', '>', '/'
|
39
|
+
])
|
40
|
+
def test_sanitize_html_text(self, html_entity):
|
41
|
+
text = '<div>Some text &{} and more</div>'.format(html_entity)
|
42
|
+
expected_text_partial = '<div>Some text &{} and more</div>'.format(
|
43
|
+
html_entity)
|
44
|
+
assert sanitize_html_text(text) == expected_text_partial
|
45
|
+
expected_text_full = '<div>Some text &{} and more</div>'.format(
|
46
|
+
html_entity)
|
47
|
+
assert sanitize_html_text(
|
48
|
+
text, replace_all_entities=True) == expected_text_full
|
49
|
+
|
50
|
+
def test_sanitize_double_delimiting_characters(self):
|
51
|
+
text = "&© &© ©; copy;;"
|
52
|
+
expected = "&© &© ©; copy;;"
|
53
|
+
assert sanitize_html_text(text, replace_all_entities=True) == expected
|
54
|
+
|
55
|
+
def test_sanitize_missing_ampersand(self):
|
56
|
+
text = "copy; lt; gt;"
|
57
|
+
expected = "copy; lt; gt;"
|
58
|
+
assert sanitize_html_text(text, replace_all_entities=True) == expected
|
59
|
+
|
60
|
+
@pytest.mark.parametrize("text, expected", [
|
61
|
+
("Some text abcdefghijklmnopqrstuvwxyz",
|
62
|
+
"Some text abcdefghijklmnopqrstuvwxyz"),
|
63
|
+
("0123456789.!?#",
|
64
|
+
"0123456789.!?#"),
|
65
|
+
("& &; &aamp; & & &",
|
66
|
+
"& &; &aamp; & & &"),
|
67
|
+
("&sool; //",
|
68
|
+
"&sool; //"),
|
69
|
+
('<div>Some text /</div>',
|
70
|
+
'<div>Some text /</div>'),
|
71
|
+
('Some text\nand more',
|
72
|
+
'Some text<br>and more'),
|
73
|
+
('<p> </p>',
|
74
|
+
'<p> </p>'),
|
75
|
+
("This 'quote' is not \"there\".",
|
76
|
+
"This 'quote' is not "there"."),
|
77
|
+
("This is a mix < than 100% & 3/5",
|
78
|
+
"This is a mix < than 100% & 3/5")
|
79
|
+
])
|
80
|
+
def test_sanitize_html_with_partial_entity_replacement(self, text, expected):
|
81
|
+
assert sanitize_html_text(text) == expected
|
82
|
+
assert sanitize_html_text(text, replace_all_entities=False) == expected
|
83
|
+
|
84
|
+
@pytest.mark.parametrize("text, expected", [
|
85
|
+
("Some text abcdefghijklmnopqrstuvwxyz",
|
86
|
+
"Some text abcdefghijklmnopqrstuvwxyz"),
|
87
|
+
("0123456789.!?#",
|
88
|
+
"0123456789.!?#"),
|
89
|
+
("& &; &aamp; & & &",
|
90
|
+
"& &; &aamp; & & &"),
|
91
|
+
("&sool; //",
|
92
|
+
"&sool; //"),
|
93
|
+
('<div>Some text /</div>',
|
94
|
+
'<div>Some text /</div>'),
|
95
|
+
('Some text\nand more',
|
96
|
+
'Some text<br>and more'),
|
97
|
+
('<p> </p>',
|
98
|
+
'<p> </p>'),
|
99
|
+
("This 'quote' is not \"there\".",
|
100
|
+
"This 'quote' is not "there"."),
|
101
|
+
("This is a mix < than 100% & 3/5",
|
102
|
+
"This is a mix < than 100% & 3/5")
|
103
|
+
])
|
104
|
+
def test_sanitize_html_with_full_entity_replacement(self, text, expected):
|
105
|
+
assert sanitize_html_text(text, replace_all_entities=True) == expected
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
12
|
+
|
13
|
+
__version__ = "0.1.0" # Dynamically set by build backend
|
14
|
+
|
15
|
+
from . import compilation
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
12
|
+
|
13
|
+
from . import html
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
12
|
+
|
13
|
+
from .html_node import HTMLNode, no_start_tag, no_end_tag, RawText
|
@@ -0,0 +1,210 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
12
|
+
|
13
|
+
import itertools
|
14
|
+
from typing import Any, Dict, List, Union
|
15
|
+
from webwidgets.utility.sanitizing import sanitize_html_text
|
16
|
+
|
17
|
+
|
18
|
+
class HTMLNode:
|
19
|
+
"""Represents an HTML node (for example, a div or a span).
|
20
|
+
"""
|
21
|
+
|
22
|
+
one_line: bool = False
|
23
|
+
|
24
|
+
def __init__(self, children: List['HTMLNode'] = [], attributes: Dict[str, str] = {}):
|
25
|
+
"""Creates an HTMLNode with optional children and attributes.
|
26
|
+
|
27
|
+
:param children: List of child HTML nodes. Defaults to an empty list.
|
28
|
+
:param attributes: Dictionary of attributes for the node. Defaults to an empty dictionary.
|
29
|
+
"""
|
30
|
+
self.children = children
|
31
|
+
self.attributes = attributes
|
32
|
+
|
33
|
+
def _get_tag_name(self) -> str:
|
34
|
+
"""Returns the tag name of the HTML node.
|
35
|
+
|
36
|
+
The tag name of a node object is the name of its class in lowercase.
|
37
|
+
|
38
|
+
:return: The tag name of the HTML node.
|
39
|
+
:rtype: str
|
40
|
+
"""
|
41
|
+
return self.__class__.__name__.lower()
|
42
|
+
|
43
|
+
def _render_attributes(self) -> str:
|
44
|
+
"""Renders the attributes of the HTML node into a string that can be added to the start tag.
|
45
|
+
|
46
|
+
:return: A string containing all attribute key-value pairs separated by spaces.
|
47
|
+
:rtype: str
|
48
|
+
"""
|
49
|
+
return ' '.join(
|
50
|
+
f'{key}="{value}"' for key, value in self.attributes.items()
|
51
|
+
)
|
52
|
+
|
53
|
+
def add(self, child: 'HTMLNode') -> None:
|
54
|
+
"""
|
55
|
+
Adds a child to the HTML node.
|
56
|
+
|
57
|
+
:param child: The child to be added.
|
58
|
+
"""
|
59
|
+
self.children.append(child)
|
60
|
+
|
61
|
+
@property
|
62
|
+
def start_tag(self) -> str:
|
63
|
+
"""Returns the opening tag of the HTML node, including any attributes.
|
64
|
+
|
65
|
+
:return: A string containing the opening tag of the element with its attributes.
|
66
|
+
:rtype: str
|
67
|
+
"""
|
68
|
+
# Rendering attributes
|
69
|
+
attributes = self._render_attributes()
|
70
|
+
maybe_space = ' ' if attributes else ''
|
71
|
+
|
72
|
+
# Building start tag
|
73
|
+
return f"<{self._get_tag_name()}{maybe_space}{attributes}>"
|
74
|
+
|
75
|
+
@property
|
76
|
+
def end_tag(self) -> str:
|
77
|
+
"""Returns the closing tag of the HTML node.
|
78
|
+
|
79
|
+
:return: A string containing the closing tag of the element.
|
80
|
+
:rtype: str
|
81
|
+
"""
|
82
|
+
return f"</{self._get_tag_name()}>"
|
83
|
+
|
84
|
+
def to_html(self, collapse_empty: bool = True,
|
85
|
+
indent_size: int = 4, indent_level: int = 0,
|
86
|
+
force_one_line: bool = False, return_lines: bool = False,
|
87
|
+
**kwargs: Any) -> Union[str, List[str]]:
|
88
|
+
"""Converts the HTML node into HTML code.
|
89
|
+
|
90
|
+
:param collapse_empty: If True, collapses empty elements into a single line.
|
91
|
+
Defaults to True.
|
92
|
+
:type collapse_empty: bool
|
93
|
+
:param indent_size: The number of spaces to use for each indentation level.
|
94
|
+
:type indent_size: int
|
95
|
+
:param indent_level: The current level of indentation in the HTML output.
|
96
|
+
:type indent_level: int
|
97
|
+
:param force_one_line: If True, forces all child elements to be rendered on a single line without additional
|
98
|
+
indentation. Defaults to False.
|
99
|
+
:type force_one_line: bool
|
100
|
+
:param return_lines: Whether to return the lines of HTML code individually. Defaults to False.
|
101
|
+
:type return_lines: bool
|
102
|
+
:param **kwargs: Additional keyword arguments to pass down to child elements.
|
103
|
+
:type **kwargs: Any
|
104
|
+
:return: A string containing the HTML representation of the element if
|
105
|
+
`return_lines` is `False` (default), or the list of individual lines
|
106
|
+
from that HTML code if `return_lines` is `True`.
|
107
|
+
:rtype: str or List[str]
|
108
|
+
"""
|
109
|
+
# Opening the element
|
110
|
+
indentation = "" if force_one_line else ' ' * indent_size * indent_level
|
111
|
+
html_lines = [indentation + self.start_tag]
|
112
|
+
|
113
|
+
# If content must be in one line
|
114
|
+
if self.one_line or force_one_line or (collapse_empty
|
115
|
+
and not self.children):
|
116
|
+
html_lines += list(itertools.chain.from_iterable(
|
117
|
+
[c.to_html(collapse_empty=collapse_empty,
|
118
|
+
indent_level=0, force_one_line=True, return_lines=True,
|
119
|
+
**kwargs)
|
120
|
+
for c in self.children]))
|
121
|
+
html_lines += [self.end_tag]
|
122
|
+
html_lines = [''.join(html_lines)] # Flattening the line
|
123
|
+
|
124
|
+
# If content spans multi-line
|
125
|
+
else:
|
126
|
+
html_lines += list(itertools.chain.from_iterable(
|
127
|
+
[c.to_html(collapse_empty=collapse_empty,
|
128
|
+
indent_size=indent_size,
|
129
|
+
indent_level=indent_level + 1,
|
130
|
+
return_lines=True,
|
131
|
+
**kwargs)
|
132
|
+
for c in self.children]))
|
133
|
+
html_lines += [indentation + self.end_tag]
|
134
|
+
html_lines = [l for l in html_lines if any(
|
135
|
+
c != ' ' for c in l)] # Trimming empty lines
|
136
|
+
|
137
|
+
# If return_lines is True, return a list of lines
|
138
|
+
if return_lines:
|
139
|
+
return html_lines
|
140
|
+
|
141
|
+
# Otherwise, return a single string
|
142
|
+
return '\n'.join(html_lines)
|
143
|
+
|
144
|
+
|
145
|
+
def no_start_tag(cls):
|
146
|
+
"""Decorator to remove the start tag from an HTMLNode subclass.
|
147
|
+
|
148
|
+
:param cls: A subclass of HTMLNode whose start tag should be removed.
|
149
|
+
:return: The given class with an empty start tag.
|
150
|
+
"""
|
151
|
+
cls.start_tag = property(
|
152
|
+
lambda _: '', doc="This element does not have a start tag")
|
153
|
+
return cls
|
154
|
+
|
155
|
+
|
156
|
+
def no_end_tag(cls):
|
157
|
+
"""Decorator to remove the end tag from an HTMLNode subclass.
|
158
|
+
|
159
|
+
:param cls: A subclass of HTMLNode whose end tag should be removed.
|
160
|
+
:return: The given class with an empty end tag.
|
161
|
+
"""
|
162
|
+
cls.end_tag = property(
|
163
|
+
lambda _: '', doc="This element does not have an end tag")
|
164
|
+
return cls
|
165
|
+
|
166
|
+
|
167
|
+
@no_start_tag
|
168
|
+
@no_end_tag
|
169
|
+
class RawText(HTMLNode):
|
170
|
+
"""A raw text node that contains text without any HTML tags."""
|
171
|
+
|
172
|
+
one_line = True
|
173
|
+
|
174
|
+
def __init__(self, text: str):
|
175
|
+
"""Creates a raw text node.
|
176
|
+
|
177
|
+
:param text: The text content of the node. It will be sanitized in
|
178
|
+
:py:meth:`RawText.to_html` before being written into HTML code.
|
179
|
+
:type text: str
|
180
|
+
"""
|
181
|
+
super().__init__()
|
182
|
+
self.text = text
|
183
|
+
|
184
|
+
def to_html(self, indent_size: int = 4, indent_level: int = 0,
|
185
|
+
return_lines: bool = False, replace_all_entities: bool = False,
|
186
|
+
**kwargs: Any) -> Union[str, List[str]]:
|
187
|
+
"""Converts the raw text node to HTML.
|
188
|
+
|
189
|
+
The text is sanitized by the :py:func:`sanitize_html_text` function before
|
190
|
+
being written into HTML code.
|
191
|
+
|
192
|
+
:param indent_size: See :py:meth:`HTMLNode.to_html`.
|
193
|
+
:type indent_size: int
|
194
|
+
:param indent_level: See :py:meth:`HTMLNode.to_html`.
|
195
|
+
:type indent_level: int
|
196
|
+
:param return_lines: See :py:meth:`HTMLNode.to_html`.
|
197
|
+
:type return_lines: bool
|
198
|
+
:param replace_all_entities: See :py:func:`sanitize_html_text`.
|
199
|
+
:type replace_all_entities: bool
|
200
|
+
:param kwargs: Other keyword arguments. These are ignored.
|
201
|
+
:type kwargs: Any
|
202
|
+
:return: See :py:meth:`HTMLNode.to_html`.
|
203
|
+
:rtype: str or List[str]
|
204
|
+
"""
|
205
|
+
sanitized = sanitize_html_text(
|
206
|
+
self.text, replace_all_entities=replace_all_entities)
|
207
|
+
line = ' ' * indent_size * indent_level + sanitized
|
208
|
+
if return_lines:
|
209
|
+
return [line]
|
210
|
+
return line
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
12
|
+
|
13
|
+
from .sanitizing import *
|
@@ -0,0 +1,132 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
12
|
+
|
13
|
+
from html.entities import html5 as HTML_ENTITIES
|
14
|
+
import re
|
15
|
+
from typing import Tuple
|
16
|
+
|
17
|
+
|
18
|
+
# Maps characters to their corresponding character references. If a character can be
|
19
|
+
# represented by multiple entities, the preferred one is placed first in the tuple.
|
20
|
+
# Preference is given to the shortest one with a semicolon, in lowercase if possible
|
21
|
+
# (e.g. "&").
|
22
|
+
CHAR_TO_HTML_ENTITIES = {v: sorted([
|
23
|
+
k for k in HTML_ENTITIES if HTML_ENTITIES[k] == v
|
24
|
+
], key=len) for v in HTML_ENTITIES.values()}
|
25
|
+
for _, entities in CHAR_TO_HTML_ENTITIES.items():
|
26
|
+
e = next((e for e in entities if ';' in e), entities[0])
|
27
|
+
i = entities.index(e.lower() if e.lower() in entities else e)
|
28
|
+
entities[i], entities[0] = entities[0], entities[i]
|
29
|
+
CHAR_TO_HTML_ENTITIES = {k: tuple(v)
|
30
|
+
for k, v in CHAR_TO_HTML_ENTITIES.items()}
|
31
|
+
|
32
|
+
|
33
|
+
# Regular expression mathing all isolated '&' characters that are not part of an
|
34
|
+
# HTML entity.
|
35
|
+
_REGEX_AMP = re.compile(f"&(?!({'|'.join(HTML_ENTITIES.keys())}))")
|
36
|
+
|
37
|
+
|
38
|
+
# Regular expression matching all isolated ';' characters that are not part of an
|
39
|
+
# HTML entity. The expression essentially concatenates one lookbehind per entity.
|
40
|
+
_REGEXP_SEMI = re.compile(
|
41
|
+
''.join(f"(?<!&{e.replace(';', '')})"
|
42
|
+
for e in HTML_ENTITIES if ';' in e) + ';')
|
43
|
+
|
44
|
+
|
45
|
+
# Entities that are always replaced during sanitization. These are: <, >, /,
|
46
|
+
# according to rule 13.1.2.6 of the HTML5 specification, as well as single quotes
|
47
|
+
# ', double quotes ", and new line characters '\n'.
|
48
|
+
# Source: https://html.spec.whatwg.org/multipage/syntax.html#cdata-rcdata-restrictions
|
49
|
+
_ALWAYS_SANITIZED = ("\u003C", "\u003E", "\u002F", "'", "\"", "\n")
|
50
|
+
|
51
|
+
|
52
|
+
# Entities other than new line characters '\n' (which require special treatment)
|
53
|
+
# that are always replaced during sanitization.
|
54
|
+
_ALWAYS_SANITIZED_BUT_NEW_LINES = tuple(
|
55
|
+
e for e in _ALWAYS_SANITIZED if e != '\n')
|
56
|
+
|
57
|
+
|
58
|
+
# Entities other than the ampersand and semicolon (which require special treatment
|
59
|
+
# because they are part of other entities) that are replaced by default during
|
60
|
+
# sanitization but can also be skipped for speed. This set of entities consists of
|
61
|
+
# all remaining entities but the ampersand and semicolon.
|
62
|
+
_OPTIONALLY_SANITIZED_BUT_AMP_SEMI = tuple(
|
63
|
+
set(CHAR_TO_HTML_ENTITIES.keys()) - set(_ALWAYS_SANITIZED) - set({'&', ';'}))
|
64
|
+
|
65
|
+
|
66
|
+
def replace_html_entities(text: str, characters: Tuple[str]) -> str:
|
67
|
+
"""Replaces characters with their corresponding HTML entities in the given text.
|
68
|
+
|
69
|
+
If a character can be represented by multiple entities, preference is given to
|
70
|
+
the shortest one that contains a semicolon, in lowercase if possible.
|
71
|
+
|
72
|
+
:param text: The input text containing HTML entities.
|
73
|
+
:type text: str
|
74
|
+
:param characters: The characters to be replaced by their HTML entity. Usually
|
75
|
+
each item in the tuple is a single character, but some entities span
|
76
|
+
multiple characters.
|
77
|
+
:type characters: Tuple[str]
|
78
|
+
:return: The text with HTML entities replaced.
|
79
|
+
:rtype: str
|
80
|
+
"""
|
81
|
+
for c in characters:
|
82
|
+
entity = CHAR_TO_HTML_ENTITIES[c][0] # Preferred is first
|
83
|
+
text = text.replace(c, '&' + entity)
|
84
|
+
return text
|
85
|
+
|
86
|
+
|
87
|
+
def sanitize_html_text(text: str, replace_all_entities: bool = False) -> str:
|
88
|
+
"""Sanitizes raw HTML text by replacing certain characters with HTML-friendly equivalents.
|
89
|
+
|
90
|
+
Sanitization affects the following characters:
|
91
|
+
- `<`, `/`, and `>`, replaced with their corresponding HTML entities `lt;`,
|
92
|
+
`gt;`, and `sol;` according to rule 13.1.2.6 of the HTML5 specification
|
93
|
+
(see source:
|
94
|
+
https://html.spec.whatwg.org/multipage/syntax.html#cdata-rcdata-restrictions)
|
95
|
+
- single quotes `'` and double quotes `"`, replaced with their corresponding
|
96
|
+
HTML entities `apos;` and `quot;`
|
97
|
+
- new line characters '\\n', replaced with `br` tags
|
98
|
+
- if `replace_all_entities` is True, every character that can be represented by
|
99
|
+
an HTML entity is replaced with that entity. If a character can be
|
100
|
+
represented by multiple entities, preference is given to the shortest one
|
101
|
+
that contains a semicolon, in lowercase if possible.
|
102
|
+
|
103
|
+
See https://html.spec.whatwg.org/multipage/named-characters.html for a list of
|
104
|
+
all supported entities.
|
105
|
+
|
106
|
+
:param text: The raw HTML text that needs sanitization.
|
107
|
+
:type text: str
|
108
|
+
:param replace_all_entities: Whether to replace every character that can be
|
109
|
+
represented by an HTML entity. Use False to skip non-mandatory characters
|
110
|
+
and increase speed. Default is False.
|
111
|
+
:type replace_all_entities: bool
|
112
|
+
:return: The sanitized HTML text.
|
113
|
+
:rtype: str
|
114
|
+
"""
|
115
|
+
# We start with all optional HTML entities, which enables us to replace all '&'
|
116
|
+
# and ';' before subsequently introducing more of them.
|
117
|
+
if replace_all_entities:
|
118
|
+
|
119
|
+
# Replacing '&' ONLY when not part of an HTML entity itself
|
120
|
+
text = _REGEX_AMP.sub('&', text)
|
121
|
+
|
122
|
+
# Replacing ';' ONLY when not part of an HTML entity itself
|
123
|
+
text = _REGEXP_SEMI.sub(';', text)
|
124
|
+
|
125
|
+
# Replacing the remaining HTML entities
|
126
|
+
text = replace_html_entities(text, _OPTIONALLY_SANITIZED_BUT_AMP_SEMI)
|
127
|
+
|
128
|
+
# Then we replace all mandatory HTML entities
|
129
|
+
text = replace_html_entities(text, _ALWAYS_SANITIZED_BUT_NEW_LINES)
|
130
|
+
text = text.replace('\n', '<br>') # Has to be last because of < and >
|
131
|
+
|
132
|
+
return text
|