diff --git a/README.md b/README.md index dcfb8d7..1a08b77 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ a([tab] #
div({"data-for-something": "foo"}) #
+ ## With class dictionary resolution is_dark_mode = False div(class_={"dark-mode": is_dark_mode, "flex": True}) diff --git a/changelog.txt b/changelog.txt index 7f1f49f..894c0d1 100644 --- a/changelog.txt +++ b/changelog.txt @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 214ec8d..dbc1264 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/html_compose/__init__.py b/src/html_compose/__init__.py index aeab72f..b5b9e39 100644 --- a/src/html_compose/__init__.py +++ b/src/html_compose/__init__.py @@ -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 @@ -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: """ @@ -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 diff --git a/src/html_compose/base_element.py b/src/html_compose/base_element.py index ba40e5a..cebaae4 100644 --- a/src/html_compose/base_element.py +++ b/src/html_compose/base_element.py @@ -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. @@ -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: @@ -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() diff --git a/src/html_compose/base_types.py b/src/html_compose/base_types.py index b6a138a..5cf2fcd 100644 --- a/src/html_compose/base_types.py +++ b/src/html_compose/base_types.py @@ -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: diff --git a/src/html_compose/cli.py b/src/html_compose/cli.py index fe5662f..50026bd 100644 --- a/src/html_compose/cli.py +++ b/src/html_compose/cli.py @@ -1,5 +1,6 @@ import argparse import fileinput +import sys from . import translate_html @@ -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") @@ -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") @@ -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 @@ -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() diff --git a/src/html_compose/document.py b/src/html_compose/document.py index cc3c8b1..e6ccf63 100644 --- a/src/html_compose/document.py +++ b/src/html_compose/document.py @@ -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 @@ -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): @@ -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()[ diff --git a/src/html_compose/elements/__init__.py b/src/html_compose/elements/__init__.py index b659107..7445393 100644 --- a/src/html_compose/elements/__init__.py +++ b/src/html_compose/elements/__init__.py @@ -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 @@ -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__ = [ diff --git a/src/html_compose/elements/iframe_element.py b/src/html_compose/elements/iframe_element.py index deded54..ecbc55a 100644 --- a/src/html_compose/elements/iframe_element.py +++ b/src/html_compose/elements/iframe_element.py @@ -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) diff --git a/src/html_compose/elements/template_element.py b/src/html_compose/elements/template_element.py index e4bb87f..a144ae4 100644 --- a/src/html_compose/elements/template_element.py +++ b/src/html_compose/elements/template_element.py @@ -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) diff --git a/src/html_compose/gallery/__init__.py b/src/html_compose/gallery/__init__.py new file mode 100644 index 0000000..38a6481 --- /dev/null +++ b/src/html_compose/gallery/__init__.py @@ -0,0 +1,132 @@ +from collections.abc import Callable +from typing import Any, Literal + +from .. import resource +from ..base_types import Node +from . import impl + + +def showcase( + *fixtures, + kwargs: dict[str, Any] | None = None, + name: str | None = None, + category: str | None = None, + tags: list[str] | None = None, + env: str = "default", +) -> Callable: + """ + Register example fixtures for component preview and testing. + + You may set multiple showcases on the same component function with multiple + decorator uses. + + Args: + *fixtures: + Zero or more arguments to be used as example inputs for the decorated function. + + kwargs: + Optional dict of keyword arguments to be used as example inputs. + + name: + Optional human-readable name for this showcase entry. + Useful for distinguishing multiple showcases on the same component. + + category: + Optional category string for grouping showcases in galleries or documentation. + + tags: + Optional list of tag strings for filtering and organizing showcase entries. + + env: + The environment in which this showcase should be active. + Defaults to ``"default"``. + + Usage: + ```python + @showcase(example_post) + @showcase(minimal_post, name="minimal") + def post(post: blog_models.Post): + return section(...)[...] + ``` + This populates a gallery that can be viewed by running `html-compose gallery` + """ + + def decorator(func: Callable) -> Callable: + func_fixtures: list[impl.ShowcaseEntry] = ( + impl.showcase_registry.setdefault(func, []) + ) + func_fixtures.append( + impl.ShowcaseEntry(fixtures, kwargs, name, category, tags, env) + ) + + # Attach metadata for introspection + if not hasattr(func, "__showcases__"): + func.__showcases__ = [] # type: ignore[attr-defined] + func.__showcases__.extend(fixtures) # type: ignore[attr-defined] + + return func + + return decorator + + +def declare_environment( + env: str = "default", + css: list[resource.css_import | str] | None = None, + js: list[resource.js_import | str] | None = None, + fonts: list[resource.font_import_manual | resource.font_import_provider] + | None = None, + isolation: Literal["shadow-dom", "iframe", "none"] = "none", + parent: str | None = None, + body_override: Node | None = None, + parent_element: Node | None = None, +): + """ + Declare resources for a named environment. + Environments can be referenced by showcases to control their setup. + + Args: + env: Name of the environment. + + css: List of CSS imports to include. + + js: List of JS imports to include. + + fonts: List of font imports to include. + + isolation: Isolation mode for the environment. + + parent: Name of a parent environment to inherit resources from. + + body_override: Optional body content to replace the default. + + parent_element: Optional parent element for the environment. + If body_override is set, this is required and must be + an element within the body_override. + """ + if env in impl.env_registry: + raise ValueError(f"Environment '{env}' is already declared") + + if isolation == "none": + for e in impl.env_registry.values(): + if e.isolation == "none": + raise ValueError( + "Only one environment can have 'none' isolation" + ) + if body_override is not None and parent_element is None: + raise ValueError( + "If body_override is set, parent_element must also be provided" + ) + if parent_element is not None and body_override is None: + raise ValueError( + "If parent_element is set, body_override must also be provided" + ) + + impl.env_registry[env] = impl.ShowcaseEnvironment( + css=css or [], + js=js or [], + fonts=fonts or [], + isolation=isolation, + parent=parent, + body_override=body_override, + parent_element=parent_element, + ) diff --git a/src/html_compose/gallery/app.py b/src/html_compose/gallery/app.py new file mode 100644 index 0000000..bec70a2 --- /dev/null +++ b/src/html_compose/gallery/app.py @@ -0,0 +1,197 @@ +import re +from typing import cast +from urllib.parse import urlencode + +import html_compose as ht +from html_compose.base_types import Node +from html_compose.document import _livereload_script_tag, document_generator +from html_compose.elements import div, meta, title + +from .. import resource +from .impl import ShowcaseResult, resolve_environment +from .resources import GALLERY_STYLES, NAV_STYLES, IframeScripts + + +class Settings: + livereload_host = "localhost" + livereload_port = 51353 + + +def doc(body): + return document_generator( + head=[ + meta(charset="UTF-8"), + meta( + name="viewport", content="width=device-width, initial-scale=1" + ), + ht.style()[GALLERY_STYLES], + title["Gallery"], + _livereload_script_tag( + dict( + host=Settings.livereload_host, + port=Settings.livereload_port, + proxy_host=None, + proxy_uri=None, + ) + ), + ], + body=body, + ) + + +def showcase_id(result: ShowcaseResult) -> str: + """Generate a stable ID for a showcase, used for anchors and element IDs.""" + base = f"{result.func_module}-{result.func_name}" + if result.name: + base += f"-{result.name}" + # Sanitize for use as HTML id - keep only alphanumeric and hyphens + sanitized = re.sub(r"[^a-zA-Z0-9-]", "-", base) + # Collapse multiple hyphens + sanitized = re.sub(r"-+", "-", sanitized) + return sanitized.lower().strip("-") + + +def main_left_panel(modules: dict[str, list[ShowcaseResult]]): + panel_sections = [] + + for module, results in modules.items(): + showcases_by_function: dict[str, list[tuple[str, ShowcaseResult]]] = {} + function_groups = [] + for r in results: + grouped_showcases = showcases_by_function.setdefault( + r.func_name, [] + ) + # Use showcase name, or function name with variant number if unnamed + if r.name: + display = r.name + else: + variant_num = len(grouped_showcases) + 1 + display = f"{r.func_name.replace('_', ' ')} #{variant_num}" + grouped_showcases.append((display, r)) + + for call_name, grouped_showcases in showcases_by_function.items(): + showcase_links = [ + ht.li()[ht.a(href=f"#{showcase_id(result)}")[name]] + for name, result in grouped_showcases + ] + function_groups.append( + ht.details()[ht.summary()[call_name], ht.ul()[showcase_links]] + ) + + module_section = ht.details(open=True)[ + ht.summary()[module], ht.ul()[function_groups] + ] + panel_sections.append(module_section) + return [ht.style()[NAV_STYLES], ht.ul()[panel_sections]] + + +def get_showcase(result: ShowcaseResult) -> Node: + """Create a self-contained page for a single showcase entry.""" + env = resolve_environment(result.env) + # Use the showcase name if provided, otherwise use the function name + display_name = result.name or result.func_name.replace("_", " ") + resource_elements = resource.to_elements( + js=env.js, css=env.css, fonts=env.fonts + ) + broken = result.success is False + card_id = showcase_id(result) + standalone_url = f"/showcase/{result.func_module}/{result.func_name}" + if result.name: + standalone_url += f"?{urlencode({'name': result.name})}" + + header = ht.div(class_="o-showcase-header")[ + ht.span(class_="o-showcase-title")[ + "[broken] " if broken else "", display_name + ], + ht.a( + class_="o-showcase-link", + href=standalone_url, + target="_blank", + title="Open standalone", + )["↗"], + ] + + if env.isolation == "shadow-dom": + content = ht.div(class_="o-showcase-content")[ + ht.template(shadowrootmode="open")[ + resource_elements, ht.unsafe_text(cast(str, result.result)) + ] + ] + elif env.isolation == "iframe": + iframe_content = [ + # inject CSS to reliably hide scrollbars + ht.style["html, body { overflow: hidden; }"], + resource_elements, + ht.unsafe_text(cast(str, result.result)), + ] + content = ht.iframe( + id=f"iframe-{card_id}", + class_="o-showcase-content", + srcdoc=ht.render(iframe_content), + ) + else: + # no isolation + content = ht.div(class_="o-showcase-content")[ + ht.unsafe_text(cast(str, result.result)) + ] + + return ht.div(class_="o-showcase-card", id=card_id)[header, content] + + +def get_showcases(content: list[ShowcaseResult]) -> list[Node]: + return [get_showcase(result) for result in content] + + +def main_body( + modules: dict[str, list[ShowcaseResult]], content: list[ShowcaseResult] +): + resources = None + for e in content: + env = resolve_environment(e.env) + # There can only be one 'none' isolation environment and it + # sets up the whole page + if env.isolation == "none": + resources = resource.to_elements( + js=env.js, css=env.css, fonts=env.fonts + ) + + return [ + resources, + ht.script()[ht.unsafe_text(IframeScripts.parent)], + div(class_="o-gallery-container")[ + div(class_="o-left-panel")[ + ht.template(shadowrootmode="open")[main_left_panel(modules)] + ], + div(class_="o-center-panel")[get_showcases(content)], + div(class_="o-right-panel"), + ], + ] + + +def main_route(content: list[ShowcaseResult]) -> str: + """ + Main gallery page showing all showcases + """ + modules: dict[str, list[ShowcaseResult]] = {} + for result in content: + modules.setdefault(result.func_module, []).append(result) + + body_content = main_body(modules, content) + return doc(body_content) + + +def generate_showcase(result: ShowcaseResult) -> str: + """ + Single page view of a showcase + """ + assert result.success, "Cannot generate showcase for failed result" + assert isinstance(result.result, str) + env = resolve_environment(result.env) + resources = resource.to_elements(js=env.js, css=env.css, fonts=env.fonts) + return doc( + [ + resources, + ht.script()[ht.unsafe_text(IframeScripts.parent)], + ht.unsafe_text(result.result), + ] + ) diff --git a/src/html_compose/gallery/impl.py b/src/html_compose/gallery/impl.py new file mode 100644 index 0000000..e1bf7ac --- /dev/null +++ b/src/html_compose/gallery/impl.py @@ -0,0 +1,165 @@ +import weakref +from collections.abc import Callable +from typing import Any, Literal, NamedTuple + +from .. import render, resource +from ..base_types import Node + + +class ShowcaseEntry(NamedTuple): + fixtures: Any + kwargs: dict[str, Any] | None + name: str | None + category: str | None + tags: list[str] | None + env: str + + +class ShowcaseResult(NamedTuple): + name: str | None + func_name: str + func_module: str + category: str | None + tags: list[str] | None + success: bool + result: str | Exception + env: str + + +class ShowcaseEnvironment(NamedTuple): + css: list[resource.css_import | str] + js: list[resource.js_import | str] + fonts: list[resource.font_import_manual | resource.font_import_provider] + isolation: Literal["shadow-dom", "iframe", "none"] + parent: str | None + body_override: Node | None + parent_element: Node | None + + +showcase_registry: weakref.WeakKeyDictionary[Callable, list[ShowcaseEntry]] = ( + weakref.WeakKeyDictionary() +) +env_registry: dict[str, ShowcaseEnvironment] = {} + + +def resolve_environment(env_name: str) -> ShowcaseEnvironment: + """ + Resolve the environment by name, including inheritance. + """ + if env_name not in env_registry: + if env_name == "default": + # Default environment + return ShowcaseEnvironment( + css=[], + js=[], + fonts=[], + isolation="none", + parent=None, + body_override=None, + parent_element=None, + ) + + raise ValueError(f"Environment '{env_name}' is not declared") + + env = env_registry[env_name] + + css: list[resource.css_import | str] = env.css + js: list[resource.js_import | str] = env.js + fonts: list[resource.font_import_manual | resource.font_import_provider] = ( + env.fonts + ) + parent = env.parent + parent_set = set() + body_inherited = None + parent_element_inherited = None + while parent: + # prevent infinite loop + if parent in parent_set: + raise ValueError( + f"Circular environment inheritance detected at '{parent}'" + ) + parent_set.add(parent) + + # Resolve parent + parent_env = env_registry.get(parent) + if not parent_env: + raise ValueError(f"Environment '{parent}' is not declared") + + # Add its resources + css = parent_env.css + css + js = parent_env.js + js + fonts = parent_env.fonts + fonts + # If we haven't previously inherited a body, take this one + if body_inherited is None and parent_env.body_override is not None: + body_inherited = parent_env.body_override + parent_element_inherited = parent_env.parent_element + + parent = parent_env.parent + + body_override = ( + env.body_override if env.body_override is not None else body_inherited + ) + parent_element = ( + env.parent_element + if env.parent_element is not None + else parent_element_inherited + ) + return ShowcaseEnvironment( + css=css, + js=js, + fonts=fonts, + isolation=env.isolation, + parent=env.parent, + body_override=body_override, + parent_element=parent_element, + ) + + +def prepare(fn, showcase_entry: ShowcaseEntry) -> tuple[bool, str | Exception]: + """ + Prepare the function with the given showcase entry. + This may involve setting up the environment, injecting fixtures, etc. + """ + try: + env = resolve_environment(showcase_entry.env) + component = fn( + *showcase_entry.fixtures, **(showcase_entry.kwargs or {}) + ) + + if env.body_override is not None and env.parent_element is not None: + env.parent_element.append(component) # type: ignore + try: + html = render(env.body_override) + finally: + env.parent_element._children.pop() # type: ignore + else: + html = render(component) + except Exception as e: + return False, e + return True, html + + +def show_showcases( + filter_allows: Callable[[ShowcaseEntry], bool] | None = None, +) -> list[ShowcaseResult]: + """ + Return all registered showcases, optionally filtered by a predicate. + """ + all_showcases: list[ShowcaseResult] = [] + for func, entries in showcase_registry.items(): + for entry in entries: + if filter_allows is None or filter_allows(entry): + success, result = prepare(func, entry) + all_showcases.append( + ShowcaseResult( + name=entry.name, + func_name=func.__qualname__, + func_module=func.__module__, + category=entry.category, + tags=entry.tags, + success=success, + result=result, + env=entry.env, + ) + ) + return all_showcases diff --git a/src/html_compose/gallery/resources.py b/src/html_compose/gallery/resources.py new file mode 100644 index 0000000..eb5ec4f --- /dev/null +++ b/src/html_compose/gallery/resources.py @@ -0,0 +1,185 @@ +class IframeScripts: + parent = """ +// Iframe auto-sizing: uses adoptedStyleSheets to survive HTML morphs +window._iframeSizer ??= (() => { + const SELECTOR = "iframe.o-showcase-content"; + const sheet = new CSSStyleSheet(); + document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; + + const sync = () => { + const rules = []; + for (const frame of document.querySelectorAll(SELECTOR)) { + const height = frame.contentDocument?.body?.scrollHeight; + if (height && frame.id) rules.push(`#${CSS.escape(frame.id)} { height: ${height}px; }`); + } + // Only update when we have heights - preserves rules when iframe not yet ready + if (rules.length) sheet.replaceSync(rules.join("\\n")); + }; + + const resizeObs = new ResizeObserver(sync); + + const observe = () => { + for (const frame of document.querySelectorAll(SELECTOR)) { + if (frame.contentDocument?.body) resizeObs.observe(frame.contentDocument.body); + frame.onload = () => { + if (frame.contentDocument?.body) resizeObs.observe(frame.contentDocument.body); + sync(); + }; + } + sync(); + }; + + // MutationObserver runs indefinitely (never disconnected) + new MutationObserver(observe).observe(document.body, { childList: true, subtree: true, attributes: true }); + return { observe }; +})(); +window._iframeSizer.observe();""" + + +NAV_STYLES = """ +:host { + display: block; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + padding: 12px 0; +} +ul { + list-style: none; + margin: 0; + padding: 0; +} +details { + margin: 0; +} +summary { + cursor: pointer; + padding: 6px 16px; + color: #333; + font-weight: 500; + user-select: none; +} +summary:hover { + background: #f5f5f5; +} +details > ul { + padding-left: 12px; +} +details details summary { + font-weight: 400; + color: #666; + padding: 4px 16px; + font-size: 13px; +} +a { + display: block; + padding: 4px 16px; + color: #0066cc; + text-decoration: none; + font-size: 13px; +} +a:hover { + background: #e8f4fc; +} +""" + +GALLERY_STYLES = """ +/* Gallery base styles */ +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #f5f5f5; + color: #333; +} + +/* Main layout */ +.o-gallery-container { + display: flex; + flex-direction: row; + align-items: stretch; + height: 100vh; +} + +/* Left navigation panel */ +.o-left-panel { + flex: 0 0 240px; + overflow-y: auto; + overflow-x: hidden; + background: #fff; + border-right: 1px solid #e0e0e0; +} + +/* Hide the empty right panel */ +.o-right-panel { + display: none; +} + +/* Center content panel */ +.o-center-panel { + flex: 1; + display: flex; + flex-direction: column; + gap: 24px; + padding: 24px; + overflow-y: auto; + background: #f5f5f5; +} + +/* Showcase card container */ +.o-showcase-card { + flex-shrink: 0; /* Don't shrink - overflow and scroll instead */ + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 6px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.04); +} + +/* Showcase header */ +.o-showcase-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + font-size: 14px; + font-weight: 500; + color: #555; + background: #fafafa; + border-bottom: 1px solid #e0e0e0; +} + +.o-showcase-title { + flex: 1; +} + +.o-showcase-link { + color: #888; + text-decoration: none; + padding: 4px 8px; + border-radius: 4px; + font-size: 13px; +} + +.o-showcase-link:hover { + background: #e8e8e8; + color: #333; +} + +/* Showcase content container */ +.o-showcase-content { + background: #fff; +} + +/* Iframe showcases */ +iframe.o-showcase-content { + display: block; + border: none; + width: 100%; + flex-shrink: 0; + overflow: hidden; +} +""" diff --git a/src/html_compose/gallery/server.py b/src/html_compose/gallery/server.py new file mode 100644 index 0000000..7d159e3 --- /dev/null +++ b/src/html_compose/gallery/server.py @@ -0,0 +1,283 @@ +import http.server +import importlib +import mimetypes +import os.path +import sys +import threading +from pathlib import Path +from time import sleep +from typing import cast +from urllib.parse import parse_qs, urlparse + +from ..live import WatchCond, Watcher, livereload_server +from . import app +from .impl import ( + ShowcaseResult, + env_registry, + show_showcases, + showcase_registry, +) + + +class Settings: + resource_prefix = "/" + static_dir = Path.cwd() / "static" + + @staticmethod + def set_static_dir(path: str) -> None: + Settings.static_dir = Path(path) + + +# Common web mimetypes if the system mimetypes db fails +WEB_MIMETYPES = { + ".html": "text/html", + ".css": "text/css", + ".js": "application/javascript", + ".png": "image/png", + ".jpg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", +} + + +class GalleryRequestHandler(http.server.BaseHTTPRequestHandler): + content: list[ShowcaseResult] = [] + + def _handle(self) -> None: + parsed = urlparse(self.path) + path = parsed.path + query = parse_qs(parsed.query) + content = GalleryRequestHandler.content + + if path == "/" or path == "/index.html": + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(app.main_route(content).encode("utf-8")) + return + + for c in content: + if path == f"/showcase/{c.func_module}/{c.func_name}": + # Filter on name if provided + test_name = query.get("name", None) + if test_name: + if c.name != test_name[0]: + continue + + if not c.success: + self.send_response(500) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(f"Showcase broken: {c.result}".encode()) + return + + # Serve the showcase + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(app.generate_showcase(c).encode("utf-8")) + return + + # Try to serve static files + if self._serve_static(path): + return + + # Nothing matched + self.send_response(404) + self.end_headers() + + def do_GET(self): + # This method handles all GET requests + self._handle() + + def _serve_static(self, path: str) -> bool: + if not Settings.static_dir: + return False + prefix = Settings.resource_prefix + if path.startswith(prefix): + normalized = path.removeprefix(prefix).lstrip("/") + else: + return False + + # Collapse .. to prevent directory traversal without resolving symlinks + candidate = Path(os.path.normpath(Settings.static_dir / normalized)) + + # Not relative to static dir + if not candidate.is_relative_to( + Path(os.path.normpath(Settings.static_dir)) + ): + return False + + # Resolve symlinks for the actual file read + # as a developer server, we assume your symlinks are safe + candidate = candidate.resolve() + + if not candidate.is_file(): + return False + + mime, _ = mimetypes.guess_type(candidate.name) + if not mime: + # Fall through if the mimetype db failed to identify + # basic web types + ext = candidate.suffix + # fallback to application/octet-stream if we really + # have no idea + mime = WEB_MIMETYPES.get(ext, "application/octet-stream") + + self.send_response(200) + self.send_header("Content-Type", mime) + self.send_header("Content-Length", str(candidate.stat().st_size)) + self.end_headers() + + # Stream the file in chunks to avoid loading large files into memory + with candidate.open("rb") as f: + while True: + data = f.read(8192) + if not data: + break + self.wfile.write(data) + + return True + + +def run( + module_path: str | Path, + host: str, + port: int, + livereload_port: int, + livereload_host: str, + force_polling: bool = False, + extra_paths: list[str] | None = None, +): + # Resolve to absolute before accessing parts to handle absolute file paths safely + # If the user passed an absolute path, we inject its directory into sys.path + mp_raw = Path(module_path) + if mp_raw.is_absolute(): + if str(mp_raw.parent) not in sys.path: + sys.path.insert(0, str(mp_raw.parent)) + mp = Path(mp_raw.name) + else: + mp = mp_raw + + if mp.suffix == ".py": + mp = mp.with_suffix("") + + showcase_registry.clear() + module_name = ".".join(mp.parts) + + if module_name in sys.modules: + del sys.modules[module_name] + + importlib.invalidate_caches() + + app.Settings.livereload_host = livereload_host + app.Settings.livereload_port = livereload_port + + try: + module = importlib.import_module(module_name) + except Exception as e: + print(f"Error importing module {module_name}: {e}") + raise + assert module is not None + + watch_globs = [] + ignore_glob = [] + if extra_paths: + for ep in extra_paths: + watch_globs.append(ep) + if hasattr(module, "__path__"): + pathlist = module.__path__ + elif hasattr(module, "__file__"): + pathlist = [os.path.dirname(cast(str, module.__file__))] + else: + pathlist = [] + print("Warning: could not determine module path for file watching") + + for watch_path in pathlist: + loaded_mod_path = Path(watch_path) + watch_globs.extend([f"{loaded_mod_path / '**/*.py'}"]) + ignore_glob.extend( + [ + f"{loaded_mod_path / venv}" + for venv in [ + "venv", + ".venv", + ".virtualenv", + "virtualenv", + "env", + ".env", + ] + ] + ) + fs_watcher = WatchCond( + path_glob=watch_globs, ignore_glob=ignore_glob, action=None + ) + + watcher = Watcher([fs_watcher], force_polling=force_polling) + + def serve(): + server_address = (host, port) + httpd = http.server.ThreadingHTTPServer( + server_address, GalleryRequestHandler + ) + print(f"Serving HTTP on http://{server_address[0]}:{server_address[1]}") + httpd.serve_forever() + + server_thread = threading.Thread(target=serve, daemon=True) + server_thread.start() + livereload_thread = threading.Thread( + target=livereload_server.live_reloader, + args=(livereload_host, livereload_port), + daemon=True, + ) + livereload_thread.start() + healthy = True + backoff = 1.0 + last_error_str = None + + GalleryRequestHandler.content = show_showcases() + changed_files = [] + while True: + if healthy: + while True: + changed_files = watcher.changed() + if changed_files: + print("Changes detected, reloading module...") + break + sleep(0.1) + else: + # Poll for changes but with progressive sleep limit (backoff) + waited = 0.0 + while waited < backoff: + changed_files = watcher.changed() + if changed_files: + break + sleep(0.1) + waited += 0.1 + + if module_name in sys.modules: + del sys.modules[module_name] + + importlib.invalidate_caches() + + try: + showcase_registry.clear() + env_registry.clear() + module = importlib.import_module(module_name) + GalleryRequestHandler.content.clear() + GalleryRequestHandler.content.extend(show_showcases()) + healthy = True + backoff = 1.0 + last_error_str = None + + if changed_files: + livereload_server.reload_because( + [f.path for f in changed_files] + ) + except Exception as e: + healthy = False + current_error_str = f"Error re-importing module {module_name}: {e}" + if current_error_str != last_error_str: + print(current_error_str) + last_error_str = current_error_str + backoff = min(backoff * 2, 10.0) diff --git a/src/html_compose/live/live_server.py b/src/html_compose/live/live_server.py index b1105d1..ce13e1b 100644 --- a/src/html_compose/live/live_server.py +++ b/src/html_compose/live/live_server.py @@ -4,14 +4,10 @@ from ..util_funcs import generate_livereload_env from .livereload_server import reload_because, run_server -from .watcher import ( - ProcessTask, - ShellCommand, - Task, - TaskRunner, - WatchCond, - Watcher, -) +from .watcher import ProcessTask, Task, TaskRunner +from .watcher import ShellCommand as ShellCommand +from .watcher import WatchCond as WatchCond +from .watcher import Watcher as Watcher def _wait_for_server( diff --git a/src/html_compose/live/livereload_server.py b/src/html_compose/live/livereload_server.py index 8ee1dbf..6f4fd4e 100644 --- a/src/html_compose/live/livereload_server.py +++ b/src/html_compose/live/livereload_server.py @@ -50,6 +50,7 @@ def live_reloader(host, port): with serve(run_ws, host, port) as srv: global server server = srv + print(f"Running livereload WebSocket server at ws://{host}:{port}") server.serve_forever() @@ -59,7 +60,6 @@ def close(): def run_server(host, port) -> Server: - print(f"Starting livereload WebSocket server at ws://{host}:{port}") threading.Thread( target=live_reloader, args=(host, port), daemon=True ).start() diff --git a/src/html_compose/live/watcher.py b/src/html_compose/live/watcher.py index b12b129..608eafe 100644 --- a/src/html_compose/live/watcher.py +++ b/src/html_compose/live/watcher.py @@ -235,9 +235,10 @@ def __init__( server_reload: If False, do not reload the daemon process after a change; just the browser. """ - self.path_glob = path_glob if isinstance(path_glob, str): self.path_glob = [path_glob] + else: + self.path_glob = path_glob if not ignore_glob: ignore_glob = [] @@ -246,6 +247,17 @@ def __init__( self.ignore_glob = [ignore_glob] else: self.ignore_glob = ignore_glob + + # We assume you want all paths to be relative to CWD + + for i, p in enumerate(self.path_glob): + p = os.path.relpath(p, CWD) + ("/" if p.endswith("/") else "") + self.path_glob[i] = p + + for i, p in enumerate(self.ignore_glob): + p = os.path.relpath(p, CWD) + ("/" if p.endswith("/") else "") + self.ignore_glob[i] = p + self.server_reload = server_reload self.reload = reload if action is None: @@ -375,6 +387,7 @@ def _get_fswatch_dirs(self): continue dirs.add(as_t) + return dirs def _get_matching_rules(self, path) -> list[WatchCond]: @@ -474,7 +487,7 @@ def changed(self) -> list[Hit]: result_tuples: set[tuple[int, str]] = result for watch_id, path in result_tuples: # RustWatch returns full paths, convert to relative - path = str(Path(path).relative_to(CWD)) + path = os.path.relpath(path, CWD) matching_rules = self._get_matching_rules(path) if matching_rules: changes.append(Hit(path, matching_rules)) diff --git a/src/html_compose/resource/__init__.py b/src/html_compose/resource/__init__.py index e9ffc81..9b5c621 100644 --- a/src/html_compose/resource/__init__.py +++ b/src/html_compose/resource/__init__.py @@ -108,14 +108,12 @@ def to_elements( entry = jsi.import_map_entry() if entry: # Add to import map - name, src = entry[0:2] + name, src = entry.name, entry.src js_imports[name] = src - if len(entry) == 3: - # Scopes feature, although I can't imagine anyone - # using it. - scope = str(entry[2]) - sdict = scopes.setdefault(scope, {}) - sdict[name] = src + if entry.scope_urls: + for scope_key in entry.scope_urls: + sdict = scopes.setdefault(scope_key, {}) + sdict[name] = src else: if not isinstance(js_src, str): diff --git a/src/html_compose/resource/js_import.py b/src/html_compose/resource/js_import.py index 192e703..c8ef052 100644 --- a/src/html_compose/resource/js_import.py +++ b/src/html_compose/resource/js_import.py @@ -1,10 +1,20 @@ -from typing import Iterable, Literal +from typing import Iterable, Literal, NamedTuple from .. import elements as el from ..util_funcs import flatten_iterable, is_iterable_but_not_str from .util_funcs import _cachebust_resource_uri +class _ImportMapEntry(NamedTuple): + """ + Common structure for import map entries + """ + + name: str + src: str + scope_urls: Iterable[str] | None = None + + class js_import: """ A javascript import helper class which employs one or more of: @@ -188,9 +198,16 @@ def __init__( self.source = source self.preload = preload self.hash = hash - self.scope_url = scope_url - if scope_url and is_iterable_but_not_str(scope_url): - self.scope_url = flatten_iterable(scope_url) + self.scope_urls = scope_url + if scope_url: + if isinstance(scope_url, str): + self.scope_urls = [scope_url] + elif is_iterable_but_not_str(scope_url): + # Materialize scope URLs so they can be iterated multiple times + # (flatten_iterable returns a generator by default). + self.scope_urls = list(flatten_iterable(scope_url)) + else: + self.scope_urls = [str(scope_url)] self.crossorigin = crossorigin self.cache_bust = cache_bust self.has_link = preload @@ -219,17 +236,18 @@ def uri(self) -> str: return _cachebust_resource_uri(self.source) - def import_map_entry( - self, - ) -> tuple[str, str] | tuple[str, str, Iterable] | None: + def import_map_entry(self) -> _ImportMapEntry | None: """ Returns a tuple of (name, source, scope_url) for use in an import map """ + if self.name: - if self.scope_url is None: - return (self.name, self._src) + if self.scope_urls is None: + return _ImportMapEntry(name=self.name, src=self._src) else: - return (self.name, self._src, self.scope_url) + return _ImportMapEntry( + name=self.name, src=self._src, scope_urls=self.scope_urls + ) return None diff --git a/src/html_compose/translate_html.py b/src/html_compose/translate_html.py index dda79f1..5490882 100644 --- a/src/html_compose/translate_html.py +++ b/src/html_compose/translate_html.py @@ -14,6 +14,21 @@ SPEC_WS = r"[\t\n\r ]" +def _neighbor_tags(node: NavigableString) -> tuple[Tag | None, Tag | None]: + """Return the closest Tag siblings to the left and right of a text node.""" + + def _find_tag_sibling(node, attr) -> Tag | None: + sibling = getattr(node, attr) + while sibling and not isinstance(sibling, Tag): + sibling = getattr(sibling, attr) + return sibling if isinstance(sibling, Tag) else None + + return ( + _find_tag_sibling(node, "previous_sibling"), + _find_tag_sibling(node, "next_sibling"), + ) + + @cache def get_phrasing_tags(): """ @@ -142,7 +157,9 @@ def is_preformatted(tag_name): return tag_name in {"pre", "textarea"} -def translate(html: str, import_module: str | None = None) -> TranslateResult: +def translate( + html: str, import_module: str | None = None, constructor: bool = False +) -> TranslateResult: """ Translate HTML string into Python code representing a similar HTML structure @@ -167,7 +184,8 @@ def process_element(element: PageElement) -> str | None: tags["doctype"] = None return f"doctype({repr(dt)})" elif isinstance(element, NavigableString): - return read_string(element, None, None, phrasing_tags) + prev_tag, next_tag = _neighbor_tags(element) + return read_string(element, prev_tag, next_tag, phrasing_tags) assert isinstance(element, Tag) safe_tag_name = safe_name(element.name) @@ -233,7 +251,9 @@ def process_element(element: PageElement) -> str | None: result.append(")") else: - result.append("()") + # If no attributes, we can skip the constructor call unless + if constructor: + result.append("()") children: list[str] = [] child_nodes = list(element.children) @@ -247,20 +267,7 @@ def process_element(element: PageElement) -> str | None: continue if isinstance(child, NavigableString): - # We step backwards until we find a tag - prev_tag = next( - ( - j - for j in reversed(child_nodes[:i]) - if isinstance(j, Tag) - ), - None, - ) - # Same deal, forwards until we find a tag - next_tag = next( - (j for j in child_nodes[i + 1 :] if isinstance(j, Tag)), - None, - ) + prev_tag, next_tag = _neighbor_tags(child) processed = read_string( child, prev_tag, next_tag, phrasing_tags ) diff --git a/src/html_compose/util_funcs.py b/src/html_compose/util_funcs.py index e69481b..595e5db 100644 --- a/src/html_compose/util_funcs.py +++ b/src/html_compose/util_funcs.py @@ -1,15 +1,16 @@ """ Library utility functions -Not inteded to be used directly by library users. +Not intended to be used directly by library users. """ import inspect import json +from collections.abc import Iterable from functools import lru_cache from os import getenv from pathlib import PurePath -from typing import Any, Generator, Iterable +from typing import Any, Generator def join_attrs(k, value_trusted): diff --git a/tests/test_assumptions.py b/tests/test_assumptions.py index d1dbe9f..5e3d69a 100644 --- a/tests/test_assumptions.py +++ b/tests/test_assumptions.py @@ -1189,3 +1189,12 @@ def control_func( assert test3_delta < test1_delta * 0.5, ( "Control function should be at least 50% faster than Test 1" ) + + +def test_fn_name(): + from html_compose.elements import div + + fn = div().render + assert fn.__name__ == "render" + assert fn.__module__ == "html_compose.base_element" + assert fn.__qualname__ == "BaseElement.render" diff --git a/tests/test_element.py b/tests/test_element.py index 3c818ff..b30fbf0 100644 --- a/tests/test_element.py +++ b/tests/test_element.py @@ -72,6 +72,24 @@ def test_nested_callables(): assert a.render() == "
text
divdiv
" +def test_callable_returning_callable(): + """Callables that return callables should fully resolve.""" + calls: list[str] = [] + + def first(parent): + calls.append("first") + + def second(current): + calls.append("second") + return current.tag + + return second + + el = div()[first] + assert el.render() == "
div
" + assert calls == ["first", "second"] + + def test_resolve_none(): el = div()[None].render() assert el == "
", "Nonetype should result in empty string" @@ -256,3 +274,13 @@ def test_custom_element(): def test_callable_br(): a = div()[h.p["hi"], h.br, h.p["there"]] assert a.render() == "

hi


there

" + + +def test_list_render(): + a = [h.li[x] for x in range(5)] + assert h.render(a) == "
  • 0
  • 1
  • 2
  • 3
  • 4
  • " + + +def test_el_render(): + a = h.div()["content"] + assert h.render(a) == "
    content
    " diff --git a/tests/test_gallery.py b/tests/test_gallery.py new file mode 100644 index 0000000..41038d5 --- /dev/null +++ b/tests/test_gallery.py @@ -0,0 +1,273 @@ +import pytest + +import html_compose as ht +from html_compose.gallery import declare_environment, showcase +from html_compose.gallery.impl import ( + env_registry, + resolve_environment, + show_showcases, + showcase_registry, +) + + +@pytest.fixture(autouse=True) +def clean_registries(): + """Clear global registries between tests""" + showcase_registry.clear() + env_registry.clear() + yield + showcase_registry.clear() + env_registry.clear() + + +# --- showcase decorator --- + + +def test_showcase_decorator(): + @showcase("example2", name="example_2", category="text") + @showcase("example1", name="example_1", category="text") + def my_component(text: str) -> list: + return [ht.li()[text], ht.li["Static item"]] + + showcases = showcase_registry.get(my_component, []) + assert len(showcases) == 2 + assert showcases[0].name == "example_1" + assert showcases[0].category == "text" + + assert showcases[1].name == "example_2" + assert showcases[1].category == "text" + + +def test_showcase_no_fixtures(): + """@showcase() with no fixtures registers with empty tuple""" + + @showcase() + def my_component(): + return [] + + showcases = showcase_registry.get(my_component, []) + assert len(showcases) == 1 + assert showcases[0].fixtures == () + assert showcases[0].name is None + assert showcases[0].category is None + + +def test_showcase_callable_fixture(): + """@showcase(callable) correctly treats the callable as a fixture, not the decorated function""" + + def my_fixture(): + return "test data" + + @showcase(my_fixture) + def my_component(data): + return [ht.div[data]] + + showcases = showcase_registry.get(my_component, []) + assert len(showcases) == 1 + assert showcases[0].fixtures == (my_fixture,) + + +def test_showcase_kwargs(): + @showcase("hello", kwargs={"style": "bold"}, name="with_style") + def my_component(text, style="normal"): + return ht.div[f"{text} ({style})"] + + results = show_showcases() + assert len(results) == 1 + assert results[0].success is True + assert "bold" in results[0].result + + +# --- prepare / show_showcases --- + + +def test_prepare_renders_component(): + @showcase("world") + def greeting(name): + return ht.span[f"hello {name}"] + + results = show_showcases() + assert len(results) == 1 + assert results[0].success is True + assert "hello world" in results[0].result + + +def test_prepare_catches_exceptions(): + @showcase("bad input") + def broken(x): + raise ValueError("intentional") + + results = show_showcases() + assert len(results) == 1 + assert results[0].success is False + assert isinstance(results[0].result, ValueError) + + +# --- declare_environment validation --- + + +def test_declare_environment_body_override_requires_parent_element(): + with pytest.raises( + ValueError, match="parent_element must also be provided" + ): + declare_environment( + env="test", isolation="iframe", body_override=ht.div["wrapper"] + ) + + +def test_declare_environment_parent_element_requires_body_override(): + with pytest.raises(ValueError, match="body_override must also be provided"): + declare_environment( + env="test", isolation="iframe", parent_element=ht.div() + ) + + +def test_declare_environment_duplicate_raises(): + declare_environment(env="dup", isolation="iframe") + with pytest.raises(ValueError, match="already declared"): + declare_environment(env="dup", isolation="iframe") + + +# --- body_override rendering --- + + +def test_body_override_wraps_component(): + """Component renders inside the parent_element within body_override""" + container = ht.main(class_="content") + layout = ht.div(class_="app-shell")[ht.nav["sidebar"], container] + declare_environment( + env="wrapped", + isolation="iframe", + body_override=layout, + parent_element=container, + ) + + @showcase("test content", env="wrapped") + def my_component(text): + return ht.p[text] + + results = show_showcases() + assert len(results) == 1 + assert results[0].success is True + html = results[0].result + # Component should be inside the layout + assert "app-shell" in html + assert "sidebar" in html + assert "test content" in html + + +def test_body_override_cleans_up_after_render(): + """parent_element is not permanently mutated after prepare()""" + container = ht.main() + layout = ht.div[container] + declare_environment( + env="cleanup", + isolation="iframe", + body_override=layout, + parent_element=container, + ) + + @showcase("first", env="cleanup") + def comp_a(text): + return ht.span[text] + + # Render once + results = show_showcases() + assert results[0].success is True + + # The container should be clean — no leftover children from previous render + assert len(container._children) == 0 + + +def test_body_override_multiple_showcases_same_env(): + """Multiple showcases using the same body_override env render independently""" + container = ht.main() + layout = ht.div(class_="shell")[container] + declare_environment( + env="shared", + isolation="iframe", + body_override=layout, + parent_element=container, + ) + + @showcase("alpha", name="a", env="shared") + @showcase("beta", name="b", env="shared") + def my_component(text): + return ht.p[text] + + results = show_showcases() + assert len(results) == 2 + assert results[0].success is True + assert results[1].success is True + # Decorator order: beta registered first (inner), alpha second (outer) + assert results[0].name == "b" + assert results[1].name == "a" + # Each should contain only its own content, not the other's + assert "beta" in results[0].result + assert "alpha" not in results[0].result + assert "alpha" in results[1].result + assert "beta" not in results[1].result + + +# --- environment inheritance --- + + +def test_environment_inheritance_css(): + declare_environment(env="base", isolation="iframe", css=["base.css"]) + declare_environment( + env="child", isolation="shadow-dom", parent="base", css=["child.css"] + ) + + resolved = resolve_environment("child") + assert resolved.css == ["base.css", "child.css"] + + +def test_environment_inheritance_body_override_child_wins(): + """Child's body_override takes precedence over parent's""" + parent_container = ht.div(class_="parent-container") + parent_layout = ht.div(class_="parent-layout")[parent_container] + declare_environment( + env="parent", + isolation="iframe", + body_override=parent_layout, + parent_element=parent_container, + ) + + child_container = ht.div(class_="child-container") + child_layout = ht.div(class_="child-layout")[child_container] + declare_environment( + env="child", + isolation="iframe", + parent="parent", + body_override=child_layout, + parent_element=child_container, + ) + + resolved = resolve_environment("child") + assert resolved.body_override is child_layout + assert resolved.parent_element is child_container + + +def test_environment_inheritance_body_override_falls_back_to_parent(): + """If child doesn't set body_override, inherits from parent""" + parent_container = ht.div(class_="parent-container") + parent_layout = ht.div(class_="parent-layout")[parent_container] + declare_environment( + env="parent2", + isolation="iframe", + body_override=parent_layout, + parent_element=parent_container, + ) + + declare_environment(env="child2", isolation="iframe", parent="parent2") + + resolved = resolve_environment("child2") + assert resolved.body_override is parent_layout + assert resolved.parent_element is parent_container + + +def test_environment_circular_inheritance_raises(): + declare_environment(env="a", isolation="iframe", parent="b") + declare_environment(env="b", isolation="shadow-dom", parent="a") + with pytest.raises(ValueError, match="Circular"): + resolve_environment("a") diff --git a/tests/test_resource.py b/tests/test_resource.py new file mode 100644 index 0000000..05b38c5 --- /dev/null +++ b/tests/test_resource.py @@ -0,0 +1,37 @@ +import json + +from html_compose.resource import js_import, to_elements + + +def _extract_import_map(elements): + for element in elements: + if element.tag == "script" and element.attrs.get("type") == "importmap": + rendered = element.render() + payload = rendered.split(">", 1)[1].rsplit("<", 1)[0] + return json.loads(payload) + raise AssertionError("importmap script not found") + + +def test_js_import_scoped_single(): + elements = to_elements( + js=[js_import("/static/mod.js", name="mod", scope_url="/admin")] + ) + import_map = _extract_import_map(elements) + assert import_map["imports"]["mod"].startswith("/static/mod.js") + assert import_map["scopes"]["/admin"]["mod"].startswith("/static/mod.js") + + +def test_js_import_scoped_iterable(): + elements = to_elements( + js=[ + js_import( + "/static/feature.js", + name="feature", + scope_url=["/settings", "/profile"], + ) + ] + ) + import_map = _extract_import_map(elements) + scopes = import_map["scopes"] + assert scopes["/settings"]["feature"].startswith("/static/feature.js") + assert scopes["/profile"]["feature"].startswith("/static/feature.js") diff --git a/tests/test_translator.py b/tests/test_translator.py index 169d867..10a2666 100644 --- a/tests/test_translator.py +++ b/tests/test_translator.py @@ -1,6 +1,18 @@ from bs4 import BeautifulSoup import html_compose.translate_html as t +from html_compose import render as render_nodes + + +def _render_translated(html: str) -> str: + tresult = t.translate(html) + env: dict[str, object] = {"__builtins__": __builtins__} + if tresult.import_statement: + exec(tresult.import_statement, env, env) + if tresult.custom_elements: + exec("\n".join(tresult.custom_elements), env, env) + nodes = [eval(expr, env, env) for expr in tresult.elements] + return render_nodes(nodes) def test_translate(): @@ -104,17 +116,17 @@ def test_translate(): tresult = t.translate(html) def _test_translation(r: t.TranslateResult): - lines = "\n\n".join(tresult.elements) + ".render()" + render_expr = "\n\n".join(tresult.elements) + ".render()" exec(tresult.import_statement) - output = eval(lines) + output = eval(render_expr) soup1 = BeautifulSoup(output, "html.parser") soup2 = BeautifulSoup(expected.render(), "html.parser") - lines = str(soup1).splitlines() - lines2 = str(soup2).splitlines() - assert len(lines) == len(lines2) - for i, line in enumerate(lines): - assert line.strip() == lines2[i].strip(), ( - f"Line {i + 1} mismatch: {line.strip()} != {lines2[i].strip()}" + rendered_lines = str(soup1).splitlines() + expected_lines = str(soup2).splitlines() + assert len(rendered_lines) == len(expected_lines) + for i, line in enumerate(rendered_lines): + assert line.strip() == expected_lines[i].strip(), ( + f"Line {i + 1} mismatch: {line.strip()} != {expected_lines[i].strip()}" ) _test_translation(t.translate(html)) @@ -153,3 +165,25 @@ def test_script_round_trip(): print(lines) output = eval(lines) assert output == html + + +def test_inline_whitespace_between_phrasing_tags(): + html = "

    foo bar

    " + assert _render_translated(html) == html + + +def test_inline_whitespace_trimmed_outside_phrasing(): + html = "

    block

    " + expected = "

    block

    " + assert _render_translated(html) == expected + + +def test_whitespace_only_between_phrasing_tags(): + html = "

    foo bar

    " + expected = "

    foo bar

    " + assert _render_translated(html) == expected + + +def test_inline_whitespace_between_root_phrasing_tags(): + html = "foo bar" + assert _render_translated(html) == html diff --git a/tools/generate_attributes.py b/tools/generate_attributes.py index 857ab0f..e75443c 100644 --- a/tools/generate_attributes.py +++ b/tools/generate_attributes.py @@ -80,7 +80,7 @@ def global_attrs(): def other_attrs(): for element in spec: result = [] - if element == "_global_attributes": + if element in ("_global_attributes", "_special_elements"): continue attrs = spec[element]["spec"]["attributes"] if not attrs: diff --git a/tools/generate_elements.py b/tools/generate_elements.py index 8984504..44ed94a 100644 --- a/tools/generate_elements.py +++ b/tools/generate_elements.py @@ -203,9 +203,15 @@ def generate_attrs(attr_class, attr_list) -> list[processed_attr]: # -> list: def gen_elements(): result = [] attr_imports = [] + special_elements = spec["_special_elements"]["spec"] + void_elements = special_elements["void elements"] global_attrs = spec["_global_attributes"]["spec"] for element in spec: - if element in ("_global_attributes", "autonomous custom elements"): + if element in ( + "_global_attributes", + "_special_elements", + "autonomous custom elements", + ): continue split_elements = element.split(", ") for real_element in split_elements: @@ -292,7 +298,7 @@ def add_param(p): extra_attrs = "\n".join(attr_list) attr_assignment = "\n".join(assign_list) fixed_name = safe_name(real_element) - is_void_element = children == "empty" + is_void_element = real_element in void_elements comment = "" if real_element in ("link", "input", "style"): # Duplicate "title" definition diff --git a/tools/generated/elements/iframe_element.py b/tools/generated/elements/iframe_element.py index 2ba01fb..1bef276 100644 --- a/tools/generated/elements/iframe_element.py +++ b/tools/generated/elements/iframe_element.py @@ -560,7 +560,7 @@ def __init__( """ #fmt: skip super().__init__( "iframe", - void_element=True, + void_element=False, attrs=attrs, children=children ) diff --git a/tools/generated/elements/template_element.py b/tools/generated/elements/template_element.py index 1484a20..f0d38d0 100644 --- a/tools/generated/elements/template_element.py +++ b/tools/generated/elements/template_element.py @@ -531,7 +531,7 @@ def __init__( """ #fmt: skip super().__init__( "template", - void_element=True, + void_element=False, attrs=attrs, children=children ) diff --git a/tools/spec_generator.py b/tools/spec_generator.py index 9565114..9dbafdc 100644 --- a/tools/spec_generator.py +++ b/tools/spec_generator.py @@ -128,6 +128,61 @@ def parse_element_table(html): for el in Hextract.parse_table(columns, column_data) } + def parse_special_elements(html): + """ + Parse the following from the spec: + Void elements + area, base, br, col, embed, hr, img, input, + link, meta, source, track, wbr + The template element + template + Raw text elements + script, style + Escapable raw text elements + textarea, title + """ + rule = hext.Rule( + """ +

    +
    +
    + +
    +
    + + + +
    +
    + + """ + ) + + results = rule.extract(html) + payload = results[0] + html_elements = payload["html"] + # so they should come in pairs, + # dfn, then code, dfn, then code, etc. + + title_rule = hext.Rule("") + element_rule = hext.Rule("") + parsed = {} + for i in range(0, len(html_elements), 2): + title_res = title_rule.extract(hext.Html(html_elements[i])) + element_res = element_rule.extract(hext.Html(html_elements[i + 1])) + assert len(title_res) == 1, "Expected one title" + + title = title_res[0]["title"] + elements = [el["element"] for el in element_res] + parsed[title] = elements + return { + "void elements": parsed.get("Void elements"), + "raw text elements": parsed.get("Raw text elements"), + "escapable raw text elements": parsed.get( + "Escapable raw text elements" + ), + } + def parse_attr_table(html): """ Return all html attrs in format @@ -249,7 +304,11 @@ def hextract(spec_doc): if element == this_element_name: this_element["attributes"].append(attr) - spec = {"_global_attributes": global_atttrs} | elements + special_elements = Hextract.parse_special_elements(html) + spec = { + "_global_attributes": global_atttrs, + "_special_elements": special_elements, + } | elements return spec