diff --git a/puppygirl.egg-info/PKG-INFO b/puppygirl.egg-info/PKG-INFO new file mode 100644 index 0000000..5dc671d --- /dev/null +++ b/puppygirl.egg-info/PKG-INFO @@ -0,0 +1,4 @@ +Metadata-Version: 2.4 +Name: puppygirl +Version: 0.0.0 +Requires-Dist: beautifulsoup4~=4.14.2 diff --git a/puppygirl.egg-info/SOURCES.txt b/puppygirl.egg-info/SOURCES.txt new file mode 100644 index 0000000..196017e --- /dev/null +++ b/puppygirl.egg-info/SOURCES.txt @@ -0,0 +1,16 @@ +pyproject.toml +puppygirl/__init__.py +puppygirl/__main__.py +puppygirl/protocols.py +puppygirl/puppygirl.py +puppygirl/puppytype.py +puppygirl/renderer.py +puppygirl.egg-info/PKG-INFO +puppygirl.egg-info/SOURCES.txt +puppygirl.egg-info/dependency_links.txt +puppygirl.egg-info/requires.txt +puppygirl.egg-info/top_level.txt +puppygirl/elements/__init__.py +puppygirl/elements/constants.py +puppygirl/elements/domme.py +puppygirl/elements/shadow_root.py \ No newline at end of file diff --git a/puppygirl.egg-info/dependency_links.txt b/puppygirl.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/puppygirl.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/puppygirl.egg-info/requires.txt b/puppygirl.egg-info/requires.txt new file mode 100644 index 0000000..d48db3f --- /dev/null +++ b/puppygirl.egg-info/requires.txt @@ -0,0 +1 @@ +beautifulsoup4~=4.14.2 diff --git a/puppygirl.egg-info/top_level.txt b/puppygirl.egg-info/top_level.txt new file mode 100644 index 0000000..d4f4cf8 --- /dev/null +++ b/puppygirl.egg-info/top_level.txt @@ -0,0 +1 @@ +puppygirl diff --git a/puppygirl/__init__.py b/puppygirl/__init__.py index e69de29..44e7ad9 100644 --- a/puppygirl/__init__.py +++ b/puppygirl/__init__.py @@ -0,0 +1,3 @@ +from puppygirl.clonable import * +from puppygirl.pg import * +from puppygirl.elements import * diff --git a/puppygirl/__main__.py b/puppygirl/__main__.py index 89f0742..e5d1160 100644 --- a/puppygirl/__main__.py +++ b/puppygirl/__main__.py @@ -2,7 +2,8 @@ from argparse import ArgumentParser from puppygirl import Puppygirl -from elements.tag import PuppygirlTag +from elements.domme import PuppygirlDomme +from puppygirl.renderer import ClientSideRenderer, ServerSideRenderer parser = ArgumentParser("puppygirl", add_help=True) subparsers = parser.add_subparsers(dest="command") @@ -14,13 +15,15 @@ build.add_argument("-p", "--pretty", action="store_true") def main(): args = parser.parse_args() - print(vars(args)) - pg = Puppygirl([PuppygirlTag]) + + pg = Puppygirl(renderer=ClientSideRenderer(), elements=[PuppygirlDomme]) html = pg.fetch(args.input) + if args.pretty: html = html.prettify() else: html = str(html) + with open(args.output, "w") as f: f.write(html) diff --git a/puppygirl/assets/clientside.js b/puppygirl/assets/clientside.js new file mode 100644 index 0000000..90b7e0a --- /dev/null +++ b/puppygirl/assets/clientside.js @@ -0,0 +1,17 @@ +class PuppygirlDomme extends HTMLElement { + constructor() { + super() + } + + connectedCallback() { + const templateName = this.getAttribute('template') + const template = document.getElementById(templateName) + + const content = template.content + const shadow = this.attachShadow({ mode: 'open' }) + shadow.appendChild(content.cloneNode(true)) + } +} + + +customElements.define("puppygirl-domme", PuppygirlDomme) diff --git a/puppygirl/clonable.py b/puppygirl/clonable.py new file mode 100644 index 0000000..8dc372d --- /dev/null +++ b/puppygirl/clonable.py @@ -0,0 +1,7 @@ +from copy import copy +from typing import TYPE_CHECKING, Protocol, Self + +class Clonable[T = Self](Protocol): + def clone(self) -> T: + return copy(self) + diff --git a/puppygirl/elements/__init__.py b/puppygirl/elements/__init__.py index f3782f9..0f48b73 100644 --- a/puppygirl/elements/__init__.py +++ b/puppygirl/elements/__init__.py @@ -1,117 +1,3 @@ -from copy import copy -from dataclasses import InitVar, dataclass, field -from typing import TYPE_CHECKING, Self - -from bs4 import BeautifulSoup, Tag - -from protocols import Clonable, Renderable - -if TYPE_CHECKING: - from puppygirl.puppygirl import Puppygirl - from puppygirl.puppytype import ElementLike - -PgSlotName = "puppygirl-slot" -TemplateName = "template" -SlotName = "slot" - -IdAttr = "id" -NameAttr = "name" -TemplateAttr = "template" -UnnamedSlotId = "unnamed" - - -@dataclass -class PuppygirlElement(Renderable): - puppygirl: "Puppygirl" - -@dataclass -class Element(Clonable): - value: InitVar["ElementLike"] - - def __post_init__(self, value: "ElementLike"): - if isinstance(value, str): - value = BeautifulSoup(value, features = "html.parser") - - if isinstance(value, BeautifulSoup): - value = next(iter(value)) - - self.value = value - - def from_element_like(value: "ElementLike") -> Self: - if isinstance(value, Element): - return value - return Element(value) - - def clone(self) -> Self: - return Element(copy(self.value)) - - # proxy all calls to inner template - def __getattr__(self, name): - return getattr(self.value, name) - - def __getitem__(self, index): - return self.value[index] - -class TemplateSlot(Element): - is_default: bool = True - - def append(self, value): - if self.is_default: - self.is_default = False - self.value.clear() - self.value.append(value) - -@dataclass -class TemplateInstance(Element, Clonable): - slots: dict[str, Tag] = field(init = False) - slot_instances: dict[str, TemplateSlot] = field(default_factory=dict) - - def __post_init__(self, value: "ElementLike"): - Element.__post_init__(self, value) - - slots = {} - for slot in value.find_all(SlotName): - if slot.has_attr(NameAttr): - slots[slot[NameAttr]] = slot - else: - slots[UnnamedSlotId] = slot - - self.slots = slots - - def _get_slot(self, slot_name: str) -> TemplateSlot: - slot = self.slot_instances.get(slot_name) - if slot is not None: return slot - - slot = TemplateSlot(self.slots.get(slot_name)) - self.slot_instances[slot_name] = slot - return slot - - def insert_content(self, content: "ElementLike"): - slot_name = content.get(SlotName) - - if slot_name is None: - slot_name = UnnamedSlotId - - slot = self._get_slot(slot_name) - - if slot is not None: - slot.append(content) - - def remove_slots(self): - for slot in self.slots.values(): - slot.unwrap() - - -@dataclass -class Template(Element, Clonable[TemplateInstance]): - def __post_init__(self, value: "ElementLike"): - Element.__post_init__(self, value) - - if value.name != TemplateName: - raise TypeError(f"{value} is not a template") - - if not value.has_attr(IdAttr): - raise TypeError(f"{value} missing id attribute") - - def clone(self) -> TemplateInstance: - return TemplateInstance(copy(self.value)) +from puppygirl.elements.constants import * +from puppygirl.elements.domme import * +from puppygirl.elements.shadow_root import * diff --git a/puppygirl/elements/constants.py b/puppygirl/elements/constants.py new file mode 100644 index 0000000..1883967 --- /dev/null +++ b/puppygirl/elements/constants.py @@ -0,0 +1,3 @@ +IdAttr = "id" +TemplateAttr = "template" +TemplateName = "template" diff --git a/puppygirl/elements/domme.py b/puppygirl/elements/domme.py new file mode 100644 index 0000000..c532246 --- /dev/null +++ b/puppygirl/elements/domme.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from puppygirl.elements import TemplateAttr +from puppygirl.elements.shadow_root import ShadowRootMode +from puppygirl.renderer import Renderable + +if TYPE_CHECKING: + from puppygirl import Puppygirl + from puppygirl.elements import Node + from puppygirl.puppytype import RenderableElement, Templates + +@dataclass +class PuppygirlDomme(Renderable): + puppygirl: "Puppygirl" + + @property + def name(self): + return "puppygirl-domme" + + def render(self, node: "Node", templates: "Templates") -> "RenderableElement": + if not node.has_attr(TemplateAttr): + return node + + template = templates[node[TemplateAttr]] + + shadow_root = node.attach_shadow(ShadowRootMode.Open) + shadow_root.append_child(template.clone()) + + return node + diff --git a/puppygirl/elements/shadow_root.py b/puppygirl/elements/shadow_root.py new file mode 100644 index 0000000..a516f48 --- /dev/null +++ b/puppygirl/elements/shadow_root.py @@ -0,0 +1,118 @@ +from copy import copy +from dataclasses import dataclass +from enum import StrEnum +from typing import Optional, Self + +from bs4 import Tag +from puppygirl.clonable import Clonable + +TemplateName = "template" + +class ShadowRootMode(StrEnum): + Open = "open" + Closed = "closed" + +@dataclass +class Node(Clonable): + _value: Tag + + @property + def value(self): + return self._value + + def attach_shadow(self, shadow_root_mode: ShadowRootMode, clonable: Optional[bool] = None, delegates_focus: Optional[bool] = None, serializable: Optional[bool] = None): + return ShadowRoot( + host=self, + mode=shadow_root_mode, + clonable=clonable, + delegates_focus=delegates_focus, + serializable=serializable + ) + + def clone(self) -> "Self": + return Node(copy(self.value)) + + def __getattr__(self, name): + if name == '_value': + raise AttributeError() + return getattr(self._value, name) + + def __getitem__(self, index): + return self._value[index] + +@dataclass +class Template(Clonable): + _value: Node + + @property + def value(self): + return self._value + + @property + def shadow_root_mode(self) -> ShadowRootMode | None: + return self._value.get("shadowrootmode") + + @shadow_root_mode.setter + def shadow_root_mode(self, mode: ShadowRootMode): + self._value["shadowrootmode"] = mode.value + + @property + def delegates_focus(self) -> Optional[bool]: + return self._value.get("delegatesfocus") + + @delegates_focus.setter + def delegates_focus(self, value: bool): + self._value["delegatesfocus"] = value + + @property + def clonable(self) -> Optional[bool]: + return self._value.get("clonable") + + @clonable.setter + def clonable(self, value: bool): + self._value["clonable"] = value + + @property + def serializable(self) -> Optional[bool]: + return self._value.get("serializable") + + @serializable.setter + def serializable(self, value: bool): + self._value["serializable"] = value + + def clone(self) -> Self: + return Template(copy(self._value)) + + def __getattr__(self, name): + if name == '_value': + raise AttributeError() + return getattr(self._value, name) + + def __getitem__(self, index): + return self._value[index] + + def __setitem__(self, index, value): + self._value[index] = value + +@dataclass +class ShadowRoot(Clonable): + host: "Node" + mode: ShadowRootMode + + clonable: Optional[bool] = None + delegates_focus: Optional[bool] = None + serializable: Optional[bool] = None + + def append_child(self, template: Template): + template.shadow_root_mode = self.mode + + if self.clonable is not None: + template.clonable = self.clonable + + if self.delegates_focus is not None: + template.delegates_focus = self.delegates_focus + + if self.serializable is not None: + template.serializable = self.serializable + + self.host.value.insert(0, template.value) diff --git a/puppygirl/elements/tag.py b/puppygirl/elements/tag.py deleted file mode 100644 index 4328aa8..0000000 --- a/puppygirl/elements/tag.py +++ /dev/null @@ -1,30 +0,0 @@ -from dataclasses import dataclass -from typing import TYPE_CHECKING, Iterable -from elements import PuppygirlElement, SlotName, TemplateAttr - -if TYPE_CHECKING: - from elements import Element, TemplateInstance - from protocols import Clonable - from puppytype import RenderableElement, TemplateDict - -@dataclass -class PuppygirlTag(PuppygirlElement): - name: str = "puppygirl-tag" - - def apply_template(self, element: "Element", template: "Clonable[TemplateInstance]") -> Iterable["Element"]: - instance = template.clone() - - for content in element.find_all(recursive=False): - instance.insert_content(content) - del content[SlotName] - - instance.remove_slots() - return instance.value - - def render(self, element: "Element", templates: "TemplateDict") -> "RenderableElement": - if not element.has_attr(TemplateAttr): - return element - - template = templates.get(element[TemplateAttr]) - return self.apply_template(element, template) - diff --git a/puppygirl/puppygirl.py b/puppygirl/pg.py similarity index 53% rename from puppygirl/puppygirl.py rename to puppygirl/pg.py index 713b9b3..c55b0e3 100644 --- a/puppygirl/puppygirl.py +++ b/puppygirl/pg.py @@ -1,14 +1,17 @@ from typing import Callable, Iterable, Self from bs4 import BeautifulSoup, Tag -from elements import IdAttr, Renderable, Template, TemplateName -from puppytype import ElementLike, ElementLikeList, Parsable, TemplateDict +from elements import IdAttr, Template +from puppygirl.renderer import Renderable, Renderer +from puppygirl.puppytype import ElementLike, ElementLikeList, Parsable, Templates -class Puppygirl: +class Puppygirl[R: Renderer]: + renderer: R elements: list[Renderable] - templates: TemplateDict + templates: Templates - def __init__(self, elements: list[Callable[[Self], Renderable]] = [], templates: ElementLikeList = []): + def __init__(self, renderer: R, elements: list[Callable[[Self], Renderable]] = [], templates: ElementLikeList = []): + self.renderer = renderer self.templates = Puppygirl._create_template_dict(templates) self.elements = [self._instantiate(el) for el in elements] @@ -17,7 +20,7 @@ class Puppygirl: return value(self) return value - def _create_template_dict(templates: Iterable[ElementLike]) -> TemplateDict: + def _create_template_dict(templates: Iterable[ElementLike]) -> Templates: templates = [Template(t) for t in templates] return {t[IdAttr]: t for t in templates} @@ -25,11 +28,6 @@ class Puppygirl: template = Template(template) self._templates[template[IdAttr]] = template - def _find_local_templates(tree: BeautifulSoup) -> TemplateDict: - templates = tree.find_all(TemplateName) - templates = filter(lambda t: t.has_attr(IdAttr), templates) - return Puppygirl._create_template_dict(templates) - def fetch(self, path: str) -> BeautifulSoup: with open(path, "r") as f: return self.parse(f) @@ -41,16 +39,4 @@ class Puppygirl: return self.parse_tree(BeautifulSoup(value, features='html.parser')) def parse_tree(self, tree: BeautifulSoup) -> BeautifulSoup: - templates = Puppygirl._find_local_templates(tree) | self.templates - - for element in self.elements: - if hasattr(element, "name"): - for tag in tree.find_all(element.name): - new_tag = element.render(tag, templates) - if isinstance(new_tag, Iterable): - tag.extend(new_tag) - tag.unwrap() - else: - tag.replace_with(new_tag) - - return tree + return self.renderer.render(self, tree) diff --git a/puppygirl/protocols.py b/puppygirl/protocols.py deleted file mode 100644 index 36b1dc7..0000000 --- a/puppygirl/protocols.py +++ /dev/null @@ -1,16 +0,0 @@ -from copy import copy -from typing import TYPE_CHECKING, Protocol, Self, runtime_checkable - -if TYPE_CHECKING: - from elements import Element - from puppytype import RenderableElement, TemplateDict - -class Clonable[T = Self](Protocol): - def clone(self) -> T: - return copy(self) - - -@runtime_checkable -class Renderable(Protocol): - def render(self, element: "Element", templates: "TemplateDict") -> "RenderableElement": - return element diff --git a/puppygirl/puppytype.py b/puppygirl/puppytype.py index 13c9083..38a05d3 100644 --- a/puppygirl/puppytype.py +++ b/puppygirl/puppytype.py @@ -3,12 +3,10 @@ from typing import BinaryIO, Iterable, TextIO, TypeAlias, TYPE_CHECKING if TYPE_CHECKING: from string import Template from bs4 import BeautifulSoup, Tag - from puppygirl.puppygirl import TemplateInstance - from puppygirl.elements import Element + from puppygirl.elements import Node Parsable: TypeAlias = "BeautifulSoup | Tag | str | bytes | TextIO | BinaryIO" -ElementLike: TypeAlias = "BeautifulSoup | Tag | str | Element" +ElementLike: TypeAlias = "BeautifulSoup | Tag | str | Node" ElementLikeList: TypeAlias = "Iterable[ElementLike]" -RenderableElement: TypeAlias = "Element | Iterable[Element]" -Templatable: TypeAlias = "Template | TemplateInstance" -TemplateDict: TypeAlias = "dict[str, Template]" +RenderableElement: TypeAlias = "Node | Iterable[Node]" +Templates: TypeAlias = "dict[str, Template]" diff --git a/puppygirl/renderer.py b/puppygirl/renderer.py new file mode 100644 index 0000000..6cdceda --- /dev/null +++ b/puppygirl/renderer.py @@ -0,0 +1,59 @@ +from dataclasses import InitVar, dataclass, field +from importlib import resources +from typing import TYPE_CHECKING, Iterable, Protocol, runtime_checkable + +from puppygirl.elements import IdAttr, TemplateName +from puppygirl.elements.shadow_root import Node +from puppygirl.puppytype import Templates + +if TYPE_CHECKING: + from bs4 import BeautifulSoup + from puppygirl import Puppygirl + +@runtime_checkable +class Renderable(Protocol): + def render(self, tree: "BeautifulSoup") -> "BeautifulSoup": + pass + +class Renderer(Protocol): + def render(self, puppygirl: "Puppygirl", tree: "BeautifulSoup") -> "BeautifulSoup": + pass + +@dataclass +class ClientSideRenderer(Renderer): + path: InitVar[str] = 'clientside.js' + injected_js: str = field(init = False) + + def __post_init__(self, path: str): + with resources.open_text('puppygirl.assets', path) as f: + self.injected_js = f.read() + + def render(self, puppygirl: "Puppygirl", tree: "BeautifulSoup") -> "BeautifulSoup": + head = tree.find("head") + script = tree.new_tag("script", attrs={"type": "text/javascript"}) + script.append(self.injected_js) + head.append(script) + return tree + +class ServerSideRenderer(Renderer): + def _find_local_templates(puppygirl: "Puppygirl", tree: "BeautifulSoup") -> "Templates": + templates = tree.find_all(TemplateName) + templates = filter(lambda t: t.has_attr(IdAttr), templates) + return puppygirl.__class__._create_template_dict(templates) + + def render(self, puppygirl: "Puppygirl", tree: "BeautifulSoup") -> "BeautifulSoup": + templates = ServerSideRenderer._find_local_templates(puppygirl, tree) | puppygirl.templates + + for element in puppygirl.elements: + if hasattr(element, "name"): + for tag in tree.find_all(element.name): + new_tag = element.render(Node(tag).clone(), templates) + + if isinstance(new_tag, Iterable): + tag.extend(new_tag) + tag.unwrap() + else: + tag.replace_with(new_tag.value) + + return tree + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..611709c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "puppygirl" +dynamic = ["version"] +dependencies = [ + "beautifulsoup4~=4.14.2" +] +