html2gemtext 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- html2gemtext-0.0.1/PKG-INFO +106 -0
- html2gemtext-0.0.1/README.md +80 -0
- html2gemtext-0.0.1/pyproject.toml +107 -0
- html2gemtext-0.0.1/pyproject.toml.orig +114 -0
- html2gemtext-0.0.1/src/html2gemtext/__init__.py +26 -0
- html2gemtext-0.0.1/src/html2gemtext/__main__.py +22 -0
- html2gemtext-0.0.1/src/html2gemtext/_html_filter.py +328 -0
- html2gemtext-0.0.1/src/html2gemtext/convert.py +24 -0
- html2gemtext-0.0.1/src/html2gemtext/py.typed +0 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: html2gemtext
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A simple library for converting HTML to Gemtext
|
|
5
|
+
Keywords: converter,Gemini,Gemtext,HTML,Hypertext,library,Markup,parser,Small Web,smolweb
|
|
6
|
+
Author: Dave Pearson
|
|
7
|
+
Author-email: Dave Pearson <davep@davep.org>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Project-URL: Homepage, https://html2gemtext.davep.dev/
|
|
20
|
+
Project-URL: Repository, https://github.com/davep/html2gemtext
|
|
21
|
+
Project-URL: Documentation, https://html2gemtext.davep.dev/
|
|
22
|
+
Project-URL: Source, https://github.com/davep/html2gemtext
|
|
23
|
+
Project-URL: Issues, https://github.com/davep/html2gemtext/issues
|
|
24
|
+
Project-URL: Discussions, https://github.com/davep/html2gemtext/discussions
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# html2gemtext - A simple library for converting HTML to Gemtext
|
|
28
|
+
|
|
29
|
+
## Introduction
|
|
30
|
+
|
|
31
|
+
`html2gemtext` is a small and simple library that provides code for
|
|
32
|
+
converting HTML into [the hypertext markup language of the Gemini
|
|
33
|
+
project](https://geminiprotocol.net/docs/gemtext-specification.gmi).
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
`html2gemtext` is [available from
|
|
38
|
+
pypi](https://pypi.org/project/html2gemtext/) and can be installed with your
|
|
39
|
+
package installer of choice.
|
|
40
|
+
|
|
41
|
+
With `pip`:
|
|
42
|
+
|
|
43
|
+
```shell
|
|
44
|
+
pip install html2gemtext
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
With `uv`:
|
|
48
|
+
|
|
49
|
+
```shell
|
|
50
|
+
uv add html2gemtext
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
The library provides a single main conversion function called
|
|
56
|
+
`html_to_gemtext`. It is passed a string that is the HTML you wish to
|
|
57
|
+
convert, and the result is a string that is the resulting Gemtext.
|
|
58
|
+
|
|
59
|
+
A very minimal converter might look like:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import fileinput
|
|
63
|
+
from .convert import html_to_gemtext
|
|
64
|
+
|
|
65
|
+
def convert() -> None:
|
|
66
|
+
with fileinput.input() as html:
|
|
67
|
+
print(html_to_gemtext("".join(html)))
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
While it is primarily intended as a library to be used from other Python
|
|
71
|
+
code, it does contain a simple test command line tool, which can be accessed
|
|
72
|
+
either via the Python `-m` switch, or depending on your environment, via the
|
|
73
|
+
`html2gemtext` command. For example, given this content of a file called
|
|
74
|
+
`test.html`:
|
|
75
|
+
|
|
76
|
+
```html
|
|
77
|
+
<!doctype html>
|
|
78
|
+
<html lang="en">
|
|
79
|
+
<head>
|
|
80
|
+
<title>Test page</title>
|
|
81
|
+
</head>
|
|
82
|
+
|
|
83
|
+
<body>
|
|
84
|
+
<p>
|
|
85
|
+
Hello World! This is a test of the converter.
|
|
86
|
+
</p>
|
|
87
|
+
<p>
|
|
88
|
+
<a href="https://www.example.com">This is a link</a> to an external website.
|
|
89
|
+
</p>
|
|
90
|
+
</body>
|
|
91
|
+
</html>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The `html2gemtext` command (or `python -m html2gemtext`) would produce:
|
|
95
|
+
|
|
96
|
+
```gemtext
|
|
97
|
+
Hello World! This is a test of the converter.
|
|
98
|
+
|
|
99
|
+
This is a link[1] to an external website.
|
|
100
|
+
|
|
101
|
+
=> https://www.example.com 1: https://www.example.com
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
See [the main documentation](https://html2gemtext.davep.dev/) for the full API.
|
|
105
|
+
|
|
106
|
+
[//]: # (README.md ends here)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# html2gemtext - A simple library for converting HTML to Gemtext
|
|
2
|
+
|
|
3
|
+
## Introduction
|
|
4
|
+
|
|
5
|
+
`html2gemtext` is a small and simple library that provides code for
|
|
6
|
+
converting HTML into [the hypertext markup language of the Gemini
|
|
7
|
+
project](https://geminiprotocol.net/docs/gemtext-specification.gmi).
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
`html2gemtext` is [available from
|
|
12
|
+
pypi](https://pypi.org/project/html2gemtext/) and can be installed with your
|
|
13
|
+
package installer of choice.
|
|
14
|
+
|
|
15
|
+
With `pip`:
|
|
16
|
+
|
|
17
|
+
```shell
|
|
18
|
+
pip install html2gemtext
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
With `uv`:
|
|
22
|
+
|
|
23
|
+
```shell
|
|
24
|
+
uv add html2gemtext
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
The library provides a single main conversion function called
|
|
30
|
+
`html_to_gemtext`. It is passed a string that is the HTML you wish to
|
|
31
|
+
convert, and the result is a string that is the resulting Gemtext.
|
|
32
|
+
|
|
33
|
+
A very minimal converter might look like:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import fileinput
|
|
37
|
+
from .convert import html_to_gemtext
|
|
38
|
+
|
|
39
|
+
def convert() -> None:
|
|
40
|
+
with fileinput.input() as html:
|
|
41
|
+
print(html_to_gemtext("".join(html)))
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
While it is primarily intended as a library to be used from other Python
|
|
45
|
+
code, it does contain a simple test command line tool, which can be accessed
|
|
46
|
+
either via the Python `-m` switch, or depending on your environment, via the
|
|
47
|
+
`html2gemtext` command. For example, given this content of a file called
|
|
48
|
+
`test.html`:
|
|
49
|
+
|
|
50
|
+
```html
|
|
51
|
+
<!doctype html>
|
|
52
|
+
<html lang="en">
|
|
53
|
+
<head>
|
|
54
|
+
<title>Test page</title>
|
|
55
|
+
</head>
|
|
56
|
+
|
|
57
|
+
<body>
|
|
58
|
+
<p>
|
|
59
|
+
Hello World! This is a test of the converter.
|
|
60
|
+
</p>
|
|
61
|
+
<p>
|
|
62
|
+
<a href="https://www.example.com">This is a link</a> to an external website.
|
|
63
|
+
</p>
|
|
64
|
+
</body>
|
|
65
|
+
</html>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The `html2gemtext` command (or `python -m html2gemtext`) would produce:
|
|
69
|
+
|
|
70
|
+
```gemtext
|
|
71
|
+
Hello World! This is a test of the converter.
|
|
72
|
+
|
|
73
|
+
This is a link[1] to an external website.
|
|
74
|
+
|
|
75
|
+
=> https://www.example.com 1: https://www.example.com
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
See [the main documentation](https://html2gemtext.davep.dev/) for the full API.
|
|
79
|
+
|
|
80
|
+
[//]: # (README.md ends here)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "html2gemtext"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "A simple library for converting HTML to Gemtext"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = []
|
|
8
|
+
license = "MIT"
|
|
9
|
+
keywords = [
|
|
10
|
+
"converter",
|
|
11
|
+
"Gemini",
|
|
12
|
+
"Gemtext",
|
|
13
|
+
"HTML",
|
|
14
|
+
"Hypertext",
|
|
15
|
+
"library",
|
|
16
|
+
"Markup",
|
|
17
|
+
"parser",
|
|
18
|
+
"Small Web",
|
|
19
|
+
"smolweb",
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 5 - Production/Stable",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Topic :: Software Development :: Libraries",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[[project.authors]]
|
|
34
|
+
name = "Dave Pearson"
|
|
35
|
+
email = "davep@davep.org"
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://html2gemtext.davep.dev/"
|
|
39
|
+
Repository = "https://github.com/davep/html2gemtext"
|
|
40
|
+
Documentation = "https://html2gemtext.davep.dev/"
|
|
41
|
+
Source = "https://github.com/davep/html2gemtext"
|
|
42
|
+
Issues = "https://github.com/davep/html2gemtext/issues"
|
|
43
|
+
Discussions = "https://github.com/davep/html2gemtext/discussions"
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
html2gemtext = "html2gemtext.__main__:convert"
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["uv_build>=0.12.3,<0.13.0"]
|
|
50
|
+
build-backend = "uv_build"
|
|
51
|
+
|
|
52
|
+
[[tool.uv.index]]
|
|
53
|
+
name = "testpypi"
|
|
54
|
+
url = "https://test.pypi.org/simple/"
|
|
55
|
+
publish-url = "https://test.pypi.org/legacy/"
|
|
56
|
+
explicit = true
|
|
57
|
+
|
|
58
|
+
[tool.pyright]
|
|
59
|
+
venvPath = "."
|
|
60
|
+
venv = ".venv"
|
|
61
|
+
exclude = [".venv"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = [
|
|
65
|
+
"E",
|
|
66
|
+
"F",
|
|
67
|
+
"UP",
|
|
68
|
+
"B",
|
|
69
|
+
"SIM",
|
|
70
|
+
"I",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint.pycodestyle]
|
|
74
|
+
max-line-length = 120
|
|
75
|
+
|
|
76
|
+
[tool.coverage.run]
|
|
77
|
+
omit = ["tests/*"]
|
|
78
|
+
|
|
79
|
+
[tool.coverage.report]
|
|
80
|
+
exclude_lines = [
|
|
81
|
+
"pragma: no cover",
|
|
82
|
+
"def __repr__",
|
|
83
|
+
"def _repr_props",
|
|
84
|
+
"raise AssertionError",
|
|
85
|
+
"raise NotImplementedError",
|
|
86
|
+
"if __name__ == .__main__.:",
|
|
87
|
+
"if TYPE_CHECKING:",
|
|
88
|
+
'class .*\bProtocol\):',
|
|
89
|
+
'@(abc\.)?abstractmethod',
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
[dependency-groups]
|
|
93
|
+
dev = [
|
|
94
|
+
"codespell>=2.4.2",
|
|
95
|
+
"mypy>=2.1.0",
|
|
96
|
+
"pre-commit>=4.6.0",
|
|
97
|
+
"ruff>=0.15.17",
|
|
98
|
+
]
|
|
99
|
+
docs = [
|
|
100
|
+
"mkdocs>=1.6.1,<2",
|
|
101
|
+
"mkdocs-material>=9.7.6",
|
|
102
|
+
"mkdocstrings[python]>=1.0.4",
|
|
103
|
+
]
|
|
104
|
+
test = [
|
|
105
|
+
"pytest>=9.1.0",
|
|
106
|
+
"pytest-cov>=7.1.0",
|
|
107
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "html2gemtext"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "A simple library for converting HTML to Gemtext"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Dave Pearson", email = "davep@davep.org" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = []
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = [
|
|
13
|
+
"converter",
|
|
14
|
+
"Gemini",
|
|
15
|
+
"Gemtext",
|
|
16
|
+
"HTML",
|
|
17
|
+
"Hypertext",
|
|
18
|
+
"library",
|
|
19
|
+
"Markup",
|
|
20
|
+
"parser",
|
|
21
|
+
"Small Web",
|
|
22
|
+
"smolweb",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 5 - Production/Stable",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Programming Language :: Python :: 3.14",
|
|
32
|
+
"Topic :: Software Development :: Libraries",
|
|
33
|
+
"Typing :: Typed",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://html2gemtext.davep.dev/"
|
|
38
|
+
Repository = "https://github.com/davep/html2gemtext"
|
|
39
|
+
Documentation = "https://html2gemtext.davep.dev/"
|
|
40
|
+
Source = "https://github.com/davep/html2gemtext"
|
|
41
|
+
Issues = "https://github.com/davep/html2gemtext/issues"
|
|
42
|
+
Discussions = "https://github.com/davep/html2gemtext/discussions"
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
html2gemtext = "html2gemtext.__main__:convert"
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["uv_build>=0.12.3,<0.13.0"]
|
|
49
|
+
build-backend = "uv_build"
|
|
50
|
+
|
|
51
|
+
[[tool.uv.index]]
|
|
52
|
+
name = "testpypi"
|
|
53
|
+
url = "https://test.pypi.org/simple/"
|
|
54
|
+
publish-url = "https://test.pypi.org/legacy/"
|
|
55
|
+
explicit = true
|
|
56
|
+
|
|
57
|
+
[tool.pyright]
|
|
58
|
+
venvPath="."
|
|
59
|
+
venv=".venv"
|
|
60
|
+
exclude=[".venv"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = [
|
|
64
|
+
# pycodestyle
|
|
65
|
+
"E",
|
|
66
|
+
# Pyflakes
|
|
67
|
+
"F",
|
|
68
|
+
# pyupgrade
|
|
69
|
+
"UP",
|
|
70
|
+
# flake8-bugbear
|
|
71
|
+
"B",
|
|
72
|
+
# flake8-simplify
|
|
73
|
+
"SIM",
|
|
74
|
+
# isort
|
|
75
|
+
"I",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[tool.ruff.lint.pycodestyle]
|
|
79
|
+
max-line-length = 120
|
|
80
|
+
|
|
81
|
+
[tool.coverage.run]
|
|
82
|
+
omit = [
|
|
83
|
+
"tests/*"
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
[tool.coverage.report]
|
|
87
|
+
exclude_lines = [
|
|
88
|
+
"pragma: no cover",
|
|
89
|
+
"def __repr__",
|
|
90
|
+
"def _repr_props",
|
|
91
|
+
"raise AssertionError",
|
|
92
|
+
"raise NotImplementedError",
|
|
93
|
+
"if __name__ == .__main__.:",
|
|
94
|
+
"if TYPE_CHECKING:",
|
|
95
|
+
"class .*\\bProtocol\\):",
|
|
96
|
+
"@(abc\\.)?abstractmethod",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
[dependency-groups]
|
|
100
|
+
dev = [
|
|
101
|
+
"codespell>=2.4.2",
|
|
102
|
+
"mypy>=2.1.0",
|
|
103
|
+
"pre-commit>=4.6.0",
|
|
104
|
+
"ruff>=0.15.17",
|
|
105
|
+
]
|
|
106
|
+
docs = [
|
|
107
|
+
"mkdocs>=1.6.1,<2",
|
|
108
|
+
"mkdocs-material>=9.7.6",
|
|
109
|
+
"mkdocstrings[python]>=1.0.4",
|
|
110
|
+
]
|
|
111
|
+
test = [
|
|
112
|
+
"pytest>=9.1.0",
|
|
113
|
+
"pytest-cov>=7.1.0",
|
|
114
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""A simple HTML to Gemtext converter."""
|
|
2
|
+
|
|
3
|
+
##############################################################################
|
|
4
|
+
# Python imports.
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
|
|
7
|
+
######################################################################
|
|
8
|
+
# Main library information.
|
|
9
|
+
__author__ = "Dave Pearson"
|
|
10
|
+
__copyright__ = "Copyright 2026, Dave Pearson"
|
|
11
|
+
__credits__ = ["Dave Pearson"]
|
|
12
|
+
__maintainer__ = "Dave Pearson"
|
|
13
|
+
__email__ = "davep@davep.org"
|
|
14
|
+
__version__: str = version("html2gemtext")
|
|
15
|
+
__licence__ = "MIT"
|
|
16
|
+
|
|
17
|
+
##############################################################################
|
|
18
|
+
# Local imports.
|
|
19
|
+
from .convert import html_to_gemtext
|
|
20
|
+
|
|
21
|
+
##############################################################################
|
|
22
|
+
# Exports.
|
|
23
|
+
__all__ = ["html_to_gemtext"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### __init__.py ends here
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
##############################################################################
|
|
2
|
+
# Python imports.
|
|
3
|
+
import fileinput
|
|
4
|
+
|
|
5
|
+
##############################################################################
|
|
6
|
+
# Local imports.
|
|
7
|
+
from .convert import html_to_gemtext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
##############################################################################
|
|
11
|
+
def convert() -> None:
|
|
12
|
+
"""Parse the input from stdin or files and print the parsed Gemtext."""
|
|
13
|
+
with fileinput.input() as html:
|
|
14
|
+
print(html_to_gemtext("".join(html)))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
##############################################################################
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
convert()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### __main__.py ends here
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Provides a HTML filter for converting HTML to Gemtext."""
|
|
2
|
+
|
|
3
|
+
##############################################################################
|
|
4
|
+
# Python imports.
|
|
5
|
+
from html.parser import HTMLParser
|
|
6
|
+
from re import sub
|
|
7
|
+
from typing import Final, Self
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
##############################################################################
|
|
11
|
+
class ContentCapture:
|
|
12
|
+
"""A simple class to capture content."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
"""Initialise the object."""
|
|
16
|
+
self._content: list[str] = []
|
|
17
|
+
"""List that holds the captured content."""
|
|
18
|
+
|
|
19
|
+
def add(self, content: str) -> Self:
|
|
20
|
+
"""Add content to the captured content.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
content: The content to add.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Self.
|
|
27
|
+
"""
|
|
28
|
+
self._content.append(sub(r"\s\s+", " ", content).strip())
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
"""Return the captured content as a string."""
|
|
33
|
+
return " ".join(self._content)
|
|
34
|
+
|
|
35
|
+
def __bool__(self) -> bool:
|
|
36
|
+
"""Return whether the captured content is non-empty."""
|
|
37
|
+
return bool(self._content)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
##############################################################################
|
|
41
|
+
class SoloLink(ContentCapture):
|
|
42
|
+
"""A simple class to capture a solo link."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, link: str) -> None:
|
|
45
|
+
"""Initialise the object.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
link: The link to add.
|
|
49
|
+
"""
|
|
50
|
+
super().__init__()
|
|
51
|
+
self._link = link
|
|
52
|
+
"""The link to add."""
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
"""Return the solo link as a string."""
|
|
56
|
+
return f"=> {self._link} {super().__str__()}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
##############################################################################
|
|
60
|
+
class Heading(ContentCapture):
|
|
61
|
+
"""A simple class to capture a heading."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, level: int) -> None:
|
|
64
|
+
"""Initialise the object.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
level: The level of the heading.
|
|
68
|
+
"""
|
|
69
|
+
super().__init__()
|
|
70
|
+
self._level = min(level, 3)
|
|
71
|
+
"""The level of the heading."""
|
|
72
|
+
|
|
73
|
+
def __str__(self) -> str:
|
|
74
|
+
"""Return the heading as a string."""
|
|
75
|
+
return f"{'#' * self._level} {super().__str__()}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
##############################################################################
|
|
79
|
+
class ListItem(ContentCapture):
|
|
80
|
+
"""A simple class to capture a list item."""
|
|
81
|
+
|
|
82
|
+
def __init__(self) -> None:
|
|
83
|
+
"""Initialise the object."""
|
|
84
|
+
super().__init__()
|
|
85
|
+
|
|
86
|
+
def __str__(self) -> str:
|
|
87
|
+
"""Return the list item as a string."""
|
|
88
|
+
return f"* {super().__str__()}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
##############################################################################
|
|
92
|
+
class Quote(ContentCapture):
|
|
93
|
+
"""A simple class to capture a quote."""
|
|
94
|
+
|
|
95
|
+
def __init__(self) -> None:
|
|
96
|
+
"""Initialise the object."""
|
|
97
|
+
super().__init__()
|
|
98
|
+
|
|
99
|
+
def __str__(self) -> str:
|
|
100
|
+
"""Return the quote as a string."""
|
|
101
|
+
return f"> {super().__str__()}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
##############################################################################
|
|
105
|
+
class Paragraph(ContentCapture):
|
|
106
|
+
"""A simple class to capture a paragraph."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, final_newline: bool = True) -> None:
|
|
109
|
+
"""Initialise the object.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
final_newline: Whether to add a final newline to the paragraph.
|
|
113
|
+
"""
|
|
114
|
+
super().__init__()
|
|
115
|
+
self._final_newline = final_newline
|
|
116
|
+
"""Whether to add a final newline to the paragraph."""
|
|
117
|
+
self._links: list[str] = []
|
|
118
|
+
"""List that holds the links in the paragraph."""
|
|
119
|
+
self._link_id: int | None = None
|
|
120
|
+
"""The to associated with the next body of text to add."""
|
|
121
|
+
|
|
122
|
+
def add_link(self, link: str) -> Self:
|
|
123
|
+
"""Add a link to the paragraph.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
link: The link to add.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Self.
|
|
130
|
+
"""
|
|
131
|
+
self._links.append(link)
|
|
132
|
+
self._link_id = len(self._links)
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
def add(self, content: str) -> Self:
|
|
136
|
+
"""Add content to the paragraph.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
content: The content to add.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Self.
|
|
143
|
+
"""
|
|
144
|
+
super().add(
|
|
145
|
+
f"{content}[{self._link_id}]" if self._link_id is not None else content
|
|
146
|
+
)
|
|
147
|
+
self._link_id = None
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def cancel_final_newline(self) -> Self:
|
|
151
|
+
"""Cancel any request to use a final newline.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Self.
|
|
155
|
+
"""
|
|
156
|
+
self._final_newline = False
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def __str__(self) -> str:
|
|
160
|
+
"""Return the paragraph as a string."""
|
|
161
|
+
return "\n".join(
|
|
162
|
+
[
|
|
163
|
+
# The main content of the paragraph.
|
|
164
|
+
super().__str__(),
|
|
165
|
+
# Add a final newline if requested.
|
|
166
|
+
*([""] if self._final_newline else []),
|
|
167
|
+
# Add any links that were captured in the paragraph.
|
|
168
|
+
*(
|
|
169
|
+
f"=> {link} {link_id}: {link}"
|
|
170
|
+
for link_id, link in enumerate(self._links, 1)
|
|
171
|
+
),
|
|
172
|
+
# Add a final newline if requested and there are links.
|
|
173
|
+
*([""] if self._final_newline and self._links else []),
|
|
174
|
+
]
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
##############################################################################
|
|
179
|
+
class Preformatted(Paragraph):
|
|
180
|
+
"""A simple class to capture preformatted text."""
|
|
181
|
+
|
|
182
|
+
def __str__(self) -> str:
|
|
183
|
+
"""Return the preformatted text as a string."""
|
|
184
|
+
return "\n".join(
|
|
185
|
+
[
|
|
186
|
+
"```",
|
|
187
|
+
super().__str__().rstrip(),
|
|
188
|
+
"```",
|
|
189
|
+
*([""] if self._final_newline else []),
|
|
190
|
+
]
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
##############################################################################
|
|
195
|
+
class HTMLToGemtextFilter(HTMLParser):
|
|
196
|
+
"""A simple HTML to Gemtext converter."""
|
|
197
|
+
|
|
198
|
+
def __init__(self) -> None:
|
|
199
|
+
"""Initialise the object."""
|
|
200
|
+
super().__init__()
|
|
201
|
+
self._current_capture: ContentCapture | None = None
|
|
202
|
+
"""The current content capture object."""
|
|
203
|
+
self._document: list[ContentCapture] = []
|
|
204
|
+
"""The list of content capture objects for the entire document."""
|
|
205
|
+
self._ignore_next: list[str] = []
|
|
206
|
+
"""The stack of tags to ignore the next end tag for."""
|
|
207
|
+
|
|
208
|
+
def _maybe_end_last_capture(self) -> Self:
|
|
209
|
+
"""End the last capture if there is one.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Self.
|
|
213
|
+
"""
|
|
214
|
+
if self._current_capture is not None:
|
|
215
|
+
self._document.append(self._current_capture)
|
|
216
|
+
self._current_capture = None
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
220
|
+
"""Handle the start of an HTML tag.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
tag: The name of the tag.
|
|
224
|
+
attrs: A list of (name, value) pairs containing the attributes found inside the tag
|
|
225
|
+
"""
|
|
226
|
+
match tag:
|
|
227
|
+
# A link within a paragraph.
|
|
228
|
+
case "a" if isinstance(self._current_capture, Paragraph):
|
|
229
|
+
if href := dict(attrs).get("href"):
|
|
230
|
+
self._current_capture.add_link(href)
|
|
231
|
+
|
|
232
|
+
# A link outwith a paragraph.
|
|
233
|
+
case "a" if self._current_capture is None:
|
|
234
|
+
if href := dict(attrs).get("href"):
|
|
235
|
+
self._current_capture = SoloLink(href)
|
|
236
|
+
|
|
237
|
+
# A quote while there is no current capture.
|
|
238
|
+
case "blockquote" if self._current_capture is None:
|
|
239
|
+
self._maybe_end_last_capture()._current_capture = Quote()
|
|
240
|
+
|
|
241
|
+
# A break within a blockquote.
|
|
242
|
+
case "br" if isinstance(self._current_capture, Quote):
|
|
243
|
+
self._document.append(self._current_capture)
|
|
244
|
+
self._current_capture = Quote()
|
|
245
|
+
|
|
246
|
+
# A break within a paragraph.
|
|
247
|
+
case "br" if isinstance(self._current_capture, Paragraph):
|
|
248
|
+
self._document.append(self._current_capture.cancel_final_newline())
|
|
249
|
+
self._current_capture = Paragraph()
|
|
250
|
+
|
|
251
|
+
# A heading while there is no current capture.
|
|
252
|
+
case "h1" | "h2" | "h3" | "h4" | "h5" | "h6":
|
|
253
|
+
self._maybe_end_last_capture()._current_capture = Heading(
|
|
254
|
+
int(tag.removeprefix("h"))
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Any kind of list item.
|
|
258
|
+
case "li":
|
|
259
|
+
self._maybe_end_last_capture()._current_capture = ListItem()
|
|
260
|
+
|
|
261
|
+
# A paragraph while there is no current capture.
|
|
262
|
+
case "p" if self._current_capture is None:
|
|
263
|
+
self._current_capture = Paragraph()
|
|
264
|
+
|
|
265
|
+
# A paragraph within a paragraph.
|
|
266
|
+
case "p" if isinstance(self._current_capture, Paragraph):
|
|
267
|
+
self._document.append(self._current_capture)
|
|
268
|
+
self._current_capture = Paragraph()
|
|
269
|
+
|
|
270
|
+
# A paragraph within something else.
|
|
271
|
+
case "p" if self._current_capture is not None:
|
|
272
|
+
self._ignore_next.append(tag)
|
|
273
|
+
|
|
274
|
+
# A pre tag not inside anything else.
|
|
275
|
+
case "pre" if self._current_capture is None:
|
|
276
|
+
self._maybe_end_last_capture()._current_capture = Preformatted()
|
|
277
|
+
|
|
278
|
+
# A pre tag inside something else.
|
|
279
|
+
case "pre" if self._current_capture is not None:
|
|
280
|
+
self._ignore_next.append(tag)
|
|
281
|
+
|
|
282
|
+
_END_TAGS_TO_HANDLE: Final[set[str]] = {
|
|
283
|
+
"blockquote",
|
|
284
|
+
"h1",
|
|
285
|
+
"h2",
|
|
286
|
+
"h3",
|
|
287
|
+
"h4",
|
|
288
|
+
"h5",
|
|
289
|
+
"h6",
|
|
290
|
+
"li",
|
|
291
|
+
"p",
|
|
292
|
+
"pre",
|
|
293
|
+
}
|
|
294
|
+
"""The tags to handle the end of."""
|
|
295
|
+
|
|
296
|
+
def handle_endtag(self, tag: str) -> None:
|
|
297
|
+
"""Handle the end of an HTML tag.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
tag: The name of the tag.
|
|
301
|
+
"""
|
|
302
|
+
if self._current_capture and tag in self._END_TAGS_TO_HANDLE:
|
|
303
|
+
if self._ignore_next and self._ignore_next[-1] == tag:
|
|
304
|
+
self._ignore_next.pop()
|
|
305
|
+
return
|
|
306
|
+
self._document.append(self._current_capture)
|
|
307
|
+
self._current_capture = None
|
|
308
|
+
|
|
309
|
+
def handle_data(self, data: str) -> None:
|
|
310
|
+
"""Handle the data inside an HTML tag.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
data: The data inside the tag.
|
|
314
|
+
"""
|
|
315
|
+
if self._current_capture is not None and (data := data.strip()):
|
|
316
|
+
self._current_capture.add(data)
|
|
317
|
+
|
|
318
|
+
def close(self) -> None:
|
|
319
|
+
"""Close the parser and flush any remaining content."""
|
|
320
|
+
self._maybe_end_last_capture()
|
|
321
|
+
super().close()
|
|
322
|
+
|
|
323
|
+
def __str__(self) -> str:
|
|
324
|
+
"""Return the Gemtext representation of the parsed HTML."""
|
|
325
|
+
return "\n".join(str(capture) for capture in self._document)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
### _html_filter.py ends here
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Provides a simple HTML to Gemtext converter."""
|
|
2
|
+
|
|
3
|
+
##############################################################################
|
|
4
|
+
# Local imports.
|
|
5
|
+
from ._html_filter import HTMLToGemtextFilter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
##############################################################################
|
|
9
|
+
def html_to_gemtext(html_content: str) -> str:
|
|
10
|
+
"""Convert HTML content to Gemtext.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
html_content: The HTML content to convert.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
The converted Gemtext content.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
(html_filter := HTMLToGemtextFilter()).feed(html_content)
|
|
20
|
+
html_filter.close()
|
|
21
|
+
return str(html_filter)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### convert.py ends here
|
|
File without changes
|