Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ a([tab]
# <div tabindex=1></div>
div({"data-for-something": "foo"})
# <div data-for-something="foo"></div>

## With class dictionary resolution
is_dark_mode = False
div(class_={"dark-mode": is_dark_mode, "flex": True})
Expand Down
19 changes: 19 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
# 0.12.0
* Add gallery: component showcase server with live reload (`html-compose gallery`)
* `@showcase` decorator to register components with example fixtures
* `declare_environment` for managing CSS/JS/fonts and isolation modes (shadow-dom, iframe, none)
* Environment inheritance with resource merging and body_override support
* Hot-reloading dev server with file watching
* Switch livereload client from livereload-js to livereload-morph
* js_import supports multiple scopes
* render/stream public APIs now accept element lists
* Spec parser now identifies special elements
* HTML converter: don't output constructor for attr-less elements
* bugfix: improve whitespace round trip
* bugfix: nested callables are now called appropriately
* bugfix: correctly detect void elements, fix iframe/template
* bugfix: watcher rule matching
* Performance: deferred_resolve no longer generates extra list
* Performance: detect _HasHtml via hasattr instead of Protocol isinstance
* Prefer collections.abc for runtime Iterable checking

# 0.11.2
* resource module correctly places import map before preload links
* bugfix: relative paths were stripped in cache_bust=true
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "html-compose"
version = "0.11.2"
version = "0.12.0"
description = "Composable HTML generation in python"
authors = [
{ name = "jealouscloud", email = "github@noaha.org" }
Expand Down
63 changes: 59 additions & 4 deletions src/html_compose/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,18 +110,26 @@
document string:

```python
from html_compose import HTML5Document, p, script, link
from html_compose import HTML5Document, p, script, link, meta

doc: str = HTML5Document(
"Site Title",
lang="en",
head=[
script(src="/public/bundle.js"),
link(rel="stylesheet", href="/public/style.css"),
js=[
'/public/bundle.js',
],
css=[
'/public/style.css',
],
head_extra=[
meta(name='robots', content='index, follow'),
],
body=[p["Hello, world!"]],
)
```
See `html_compose.resource` for advanced resource configuration,
or view `html_compose.document` for for more on document generation.


### Composing Elements

Expand Down Expand Up @@ -208,8 +216,12 @@
.. include:: ../../doc/ideas/06_resource_imports.md
"""

from typing import Any, Generator, Iterable, cast

from markupsafe import Markup, escape

from .base_types import Node


def escape_text(value) -> Markup:
"""
Expand Down Expand Up @@ -271,6 +283,49 @@ def doctype(dtype: str = "html"):
from .base_element import BaseElement as BaseElement
from .custom_element import CustomElement as CustomElement


def stream(html_content: Iterable[Node] | Node) -> Generator[str, Any, None]:
"""
Stream one or more HTML elements as a generator of strings

:param html_content: An iterable of elements that could be rendered as HTML

:return: Generator of HTML strings
"""
last = object()

def generator() -> Generator[str, Any, None]:
# We use a dummy element to implement .resolve()
dummy = BaseElement(tag="dummy")
dummy.append(html_content)

element_source = dummy.resolve()
next(element_source, None) # Skip dummy start tag

next_item = next(element_source, last)

while True:
item = cast(str, next_item)
next_item = next(element_source, last)
if next_item is last:
# skip dummy end tag
break
yield cast(str, item)

return generator()


def render(html_content: Iterable[Node] | Node) -> str:
"""
Render one or more HTML elements into a single string

:param html_content: An iterable of elements that could be rendered as HTML

:return: A single HTML string
"""
return "".join(stream(html_content))


create_element = CustomElement.create
# Document features
from .document import HTML5Document as HTML5Document
Expand Down
9 changes: 5 additions & 4 deletions src/html_compose/base_element.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,9 @@ def _resolve_child(
inst: BaseElement = child()
yield from inst.resolve()

elif isinstance(child, _HasHtml):
yield unsafe_text(child.__html__())
elif hasattr(child, "__html__"):
# Duck typing is faster than isinstance checks on runtime Protocols
yield unsafe_text(cast(_HasHtml, child).__html__())

elif isinstance(child, str):
# Magic: If the string is already escaped, this never has to fire.
Expand Down Expand Up @@ -309,7 +310,7 @@ def _resolve_child(
else:
result = child
while callable(result):
result = self._call_callable(child, parent) # type: ignore[assignment]
result = self._call_callable(result, parent) # type: ignore[assignment]

yield from self._resolve_child(result, call_callables, parent)
else:
Expand Down Expand Up @@ -405,7 +406,7 @@ def deferred_resolve(
children = None

if not self.is_void_element:
children = [child for child in self._resolve_tree(parent)]
children = (child for child in self._resolve_tree(parent))

# join_attrs has a configurable lru_cache
join_attrs = self.get_attr_join()
Expand Down
2 changes: 2 additions & 0 deletions src/html_compose/base_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from . import util_funcs


# Note: base_element actually does runtime checking via hasattr
# This is for the type checker.
@typing.runtime_checkable
class _HasHtml(typing.Protocol):
def __html__(self) -> str:
Expand Down
84 changes: 83 additions & 1 deletion src/html_compose/cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import argparse
import fileinput
import sys

from . import translate_html

Expand All @@ -26,7 +27,9 @@ def from_html(args):
if is_stdin:
print("---\n")

tresult = translate_html.translate(html_content, args.noimport)
tresult = translate_html.translate(
html_content, args.noimport, args.constructor
)

if tresult is None:
print("Failed to translate HTML content")
Expand Down Expand Up @@ -61,6 +64,13 @@ def parse_html_translate(parser):
help="Instead of importing each element, use the specified module name",
)

parser.add_argument(
"-c",
"--constructor",
action="store_true",
help="Always output element constructor",
)


def html_convert():
parser = argparse.ArgumentParser(description="HTML to python translator")
Expand All @@ -69,6 +79,72 @@ def html_convert():
from_html(args)


def parse_gallery(parser):
parser.add_argument("module", help="Path to the module to run")
parser.add_argument(
"--host",
default="localhost",
help="Host to run the gallery server on (default: localhost)",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port to run the gallery server on (default: 8000)",
)
parser.add_argument(
"--livereload-host",
default="(host)",
help="Host to run the gallery server on (defaults to host value)",
)
parser.add_argument(
"--livereload-port",
type=int,
default=51353,
help="Port to run the gallery server on (default: 51353)",
)
parser.add_argument(
"--force-polling",
action="store_true",
help="Force polling for file changes instead of using inotify or similar",
)
parser.add_argument(
"--watch-extra-path",
action="append",
default=[],
help="Additional paths to watch for changes (can be specified multiple times)",
)
parser.add_argument(
"--python-path",
nargs="?",
help="Add path to Python search path",
default=".",
)


def gallery(args):
from .gallery.server import run

if args.python_path:
if args.python_path not in sys.path:
sys.path.insert(0, args.python_path)

try:
run(
module_path=args.module,
host=args.host,
port=args.port,
force_polling=args.force_polling,
extra_paths=args.watch_extra_path,
livereload_host=args.livereload_host
if args.livereload_host != "(host)"
else args.host,
livereload_port=args.livereload_port,
)
except KeyboardInterrupt:
print("\nExiting...")


def cli():
"""
Command-line tool to translate HTML to Python code using html_compose
Expand All @@ -83,8 +159,14 @@ def cli():
HTML_CONVERT, help="Translate HTML to html-compose"
)
parse_html_translate(html_parser)
gallery_parser = subparsers.add_parser(
"gallery", help="Run an HTML gallery server"
)
parse_gallery(gallery_parser)
args = parser.parse_args()
if args.command == HTML_CONVERT:
from_html(args)
elif args.command == "gallery":
gallery(args)
else:
parser.print_help()
38 changes: 33 additions & 5 deletions src/html_compose/document.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
"""
HTML5 document generation functions and classes.

The most featureful way to generate a full HTML5 document is to use
the `HTML5Document` class with `html_compose.resource` types managing
imports. The benefit of this is automatic and correct ordering of resources,
preloads, and import mapping.

The most control over the document generation process is to use
`document_generator` which yields parts of the document as strings.

Streaming alternatives are available via `document_streamer`, or
`HTML5Document.stream()`.

These will yield chunks as they are generated.
"""

import os
from typing import Any, Generator, Iterable, Literal, TypeAlias
from urllib.parse import urlencode

Expand Down Expand Up @@ -163,17 +181,25 @@ def document_generator(

def get_livereload_uri() -> str:
"""
Generally this is just the neat place to store the livereload URI.

Return livereload-js compatible resource URI
But if the user wants they can override this function to return a local
resource i.e.

html_compose.document.get_live_reload_uri =
lambda: "mydomain.com/static/livereload.js";

Or just set env var HTMLCOMPOSE_LIVERELOAD_URL to the desired URL.

We default to livereload-morph, which fits our use case better.

"""
VERSION = "v4.0.2"
return f"cdn.jsdelivr.net/npm/livereload-js@{VERSION}/dist/livereload.js"
env_url = os.getenv("HTMLCOMPOSE_LIVERELOAD_URL", None)
# allow user override.
if env_url:
return env_url
VERSION = "0.3.0"
# We ported from livereload-js to livereload-morph to better fit our use case
return f"cdn.jsdelivr.net/npm/livereload-morph@{VERSION}/dist/livereload-morph.js"


def _livereload_script_tag(live_reload_settings):
Expand All @@ -196,7 +222,9 @@ def _livereload_script_tag(live_reload_settings):
# Regular development enviroment with no proxy. host:port will do.
host = live_reload_settings["host"]
port = live_reload_settings["port"]
uri_encoded_flags = urlencode({"host": host, "port": port})
uri_encoded_flags = urlencode(
{"host": host, "port": port, "verbose": True}
)

# This scriptlet auto-inserts the livereload script and detects protocol
return el.script()[
Expand Down
4 changes: 2 additions & 2 deletions src/html_compose/elements/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,6 @@ def get(value: str) -> BaseAttribute:

"""

import os

from .a_element import a as a
from .abbr_element import abbr as abbr
from .address_element import address as address
Expand Down Expand Up @@ -218,6 +216,8 @@ def get(value: str) -> BaseAttribute:
from .video_element import video as video
from .wbr_element import wbr as wbr

import os

# hack: force PDOC to treat elements as submodules
if not os.environ.get("PDOC_GENERATING", False):
__all__ = [
Expand Down
2 changes: 1 addition & 1 deletion src/html_compose/elements/iframe_element.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ def __init__(

""" # fmt: skip
super().__init__(
"iframe", void_element=True, attrs=attrs, children=children
"iframe", void_element=False, attrs=attrs, children=children
)
if not (id is None or id is False):
self._process_attr("id", id)
Expand Down
2 changes: 1 addition & 1 deletion src/html_compose/elements/template_element.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ def __init__(

""" # fmt: skip
super().__init__(
"template", void_element=True, attrs=attrs, children=children
"template", void_element=False, attrs=attrs, children=children
)
if not (id is None or id is False):
self._process_attr("id", id)
Expand Down
Loading
Loading