summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-08-21 07:51:15 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-08-21 07:51:15 +0000
commitd8409535af8f9e9944c63c73f33bc7cc9577ccfb (patch)
tree5d3adfdc0309ee50bd74d974d3488ea6ba5b8229 /src
parentInitial commit. (diff)
downloadjinjax-d8409535af8f9e9944c63c73f33bc7cc9577ccfb.tar.xz
jinjax-d8409535af8f9e9944c63c73f33bc7cc9577ccfb.zip
Adding upstream version 0.45+dfsg.upstream/0.45+dfsg
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src')
-rw-r--r--src/jinjax/__init__.py5
-rw-r--r--src/jinjax/catalog.py530
-rw-r--r--src/jinjax/component.py258
-rw-r--r--src/jinjax/exceptions.py37
-rw-r--r--src/jinjax/html_attrs.py348
-rw-r--r--src/jinjax/jinjax.py161
-rw-r--r--src/jinjax/middleware.py39
-rw-r--r--src/jinjax/py.typed0
-rw-r--r--src/jinjax/utils.py16
9 files changed, 1394 insertions, 0 deletions
diff --git a/src/jinjax/__init__.py b/src/jinjax/__init__.py
new file mode 100644
index 0000000..e30f39b
--- /dev/null
+++ b/src/jinjax/__init__.py
@@ -0,0 +1,5 @@
+from .catalog import Catalog # noqa
+from .component import Component # noqa
+from .exceptions import * # noqa
+from .jinjax import JinjaX # noqa
+from .html_attrs import HTMLAttrs, LazyString # noqa
diff --git a/src/jinjax/catalog.py b/src/jinjax/catalog.py
new file mode 100644
index 0000000..c70f81d
--- /dev/null
+++ b/src/jinjax/catalog.py
@@ -0,0 +1,530 @@
+import os
+import typing as t
+from collections import UserString
+from hashlib import sha256
+from pathlib import Path
+
+import jinja2
+from markupsafe import Markup
+
+from .component import Component
+from .exceptions import ComponentNotFound, InvalidArgument
+from .html_attrs import HTMLAttrs
+from .jinjax import JinjaX
+from .middleware import ComponentsMiddleware
+from .utils import DELIMITER, SLASH, get_url_prefix, logger
+
+
+DEFAULT_URL_ROOT = "/static/components/"
+ALLOWED_EXTENSIONS = (".css", ".js", ".mjs")
+DEFAULT_PREFIX = ""
+DEFAULT_EXTENSION = ".jinja"
+ARGS_ATTRS = "attrs"
+ARGS_CONTENT = "content"
+
+
+class CallerWrapper(UserString):
+ def __init__(self, caller: t.Callable | None, content: str = "") -> None:
+ self._caller = caller
+ # Pre-calculate the defaut content so the assets are loaded
+ self._content = caller("") if caller else Markup(content)
+
+ def __call__(self, slot: str = "") -> str:
+ if slot and self._caller:
+ return self._caller(slot)
+ return self._content
+
+ def __html__(self) -> str:
+ return self()
+
+ @property
+ def data(self) -> str: # type: ignore
+ return self()
+
+
+
+class Catalog:
+ """
+ The object that manages the components and their global settings.
+
+ Arguments:
+
+ globals:
+ Dictionary of Jinja globals to add to the Catalog's Jinja environment
+ (or the one passed in `jinja_env`).
+
+ filters:
+ Dictionary of Jinja filters to add to the Catalog's Jinja environment
+ (or the one passed in `jinja_env`).
+
+ tests:
+ Dictionary of Jinja tests to add to the Catalog's Jinja environment
+ (or the one passed in `jinja_env`).
+
+ extensions:
+ List of Jinja extensions to add to the Catalog's Jinja environment
+ (or the one passed in `jinja_env`). The `jinja2.ext.do` extension is
+ always added at the end of these.
+
+ jinja_env:
+ Custom Jinja environment to use. This argument is useful to reuse an
+ existing Jinja Environment from your web framework.
+
+ root_url:
+ Add this prefix to every asset URL of the static middleware. By default,
+ it is `/static/components/`, so, for example, the URL of the CSS file of
+ a `Card` component is `/static/components/Card.css`.
+
+ You can also change this argument so the assets are requested from a
+ Content Delivery Network (CDN) in production, for example,
+ `root_url="https://example.my-cdn.com/"`.
+
+ file_ext:
+ The extensions the components files have. By default, ".jinja".
+
+ This argument can also be a list to allow more than one type of file to
+ be a component.
+
+ use_cache:
+ Cache the metadata of the component in memory.
+
+ auto_reload:
+ Used with `use_cache`. If `True`, the last-modified date of the component
+ file is checked every time to see if the cache is up-to-date.
+
+ Set to `False` in production.
+
+ fingerprint:
+ If `True`, inserts a hash of the updated time into the URL of the
+ asset files (after the name but before the extension).
+
+ This strategy encourages long-term caching while ensuring that new copies
+ are only requested when the content changes, as any modification alters the
+ fingerprint and thus the filename.
+
+ **WARNING**: Only works if the server knows how to filter the fingerprint
+ to get the real name of the file.
+
+ Attributes:
+
+ collected_css:
+ List of CSS paths collected during a render.
+
+ collected_js:
+ List of JS paths collected during a render.
+
+ prefixes:
+ Mapping between folder prefixes and the Jinja loader that uses.
+
+ """
+
+ __slots__ = (
+ "prefixes",
+ "root_url",
+ "file_ext",
+ "jinja_env",
+ "fingerprint",
+ "collected_css",
+ "collected_js",
+ "auto_reload",
+ "use_cache",
+ "_tmpl_globals",
+ "_cache",
+ )
+
+ def __init__(
+ self,
+ *,
+ globals: "dict[str, t.Any] | None" = None,
+ filters: "dict[str, t.Any] | None" = None,
+ tests: "dict[str, t.Any] | None" = None,
+ extensions: "list | None" = None,
+ jinja_env: "jinja2.Environment | None" = None,
+ root_url: str = DEFAULT_URL_ROOT,
+ file_ext: "str | tuple[str, ...]" = DEFAULT_EXTENSION,
+ use_cache: bool = True,
+ auto_reload: bool = True,
+ fingerprint: bool = False,
+ ) -> None:
+ self.prefixes: dict[str, jinja2.FileSystemLoader] = {}
+ self.collected_css: list[str] = []
+ self.collected_js: list[str] = []
+ self.file_ext = file_ext
+ self.use_cache = use_cache
+ self.auto_reload = auto_reload
+ self.fingerprint = fingerprint
+
+ root_url = root_url.strip().rstrip(SLASH)
+ self.root_url = f"{root_url}{SLASH}"
+
+ env = jinja2.Environment(undefined=jinja2.StrictUndefined)
+ extensions = [*(extensions or []), "jinja2.ext.do", JinjaX]
+ globals = globals or {}
+ filters = filters or {}
+ tests = tests or {}
+
+ if jinja_env:
+ env.extensions.update(jinja_env.extensions)
+ env.autoescape = jinja_env.autoescape
+ globals.update(jinja_env.globals)
+ filters.update(jinja_env.filters)
+ tests.update(jinja_env.tests)
+ jinja_env.globals["catalog"] = self
+ jinja_env.filters["catalog"] = self
+
+ globals["catalog"] = self
+ filters["catalog"] = self
+
+ for ext in extensions:
+ env.add_extension(ext)
+ env.globals.update(globals)
+ env.filters.update(filters)
+ env.tests.update(tests)
+ env.extend(catalog=self)
+
+ self.jinja_env = env
+
+ self._tmpl_globals: "t.MutableMapping[str, t.Any] | None" = None
+ self._cache: dict[str, dict] = {}
+
+ @property
+ def paths(self) -> list[Path]:
+ """
+ A helper property that returns a list of all the components folder paths.
+ """
+ _paths = []
+ for loader in self.prefixes.values():
+ _paths.extend(loader.searchpath)
+ return _paths
+
+ def add_folder(
+ self,
+ root_path: "str | Path",
+ *,
+ prefix: str = DEFAULT_PREFIX,
+ ) -> None:
+ """
+ Add a folder path from where to search for components, optionally under a prefix.
+
+ The prefix acts like a namespace. For example, the name of a
+ `components/Card.jinja` component is, by default, "Card",
+ but under the prefix "common", it becomes "common.Card".
+
+ The rule for subfolders remains the same: a `components/wrappers/Card.jinja`
+ name is, by default, "wrappers.Card", but under the prefix "common",
+ it becomes "common.wrappers.Card".
+
+ If there is more than one component with the same name in multiple
+ added folders under the same prefix, the one in the folder added
+ first takes precedence.
+
+ Arguments:
+
+ root_path:
+ Absolute path of the folder with component files.
+
+ prefix:
+ Optional prefix that all the components in the folder will
+ have. The default is empty.
+
+ """
+ prefix = prefix.strip().strip(f"{DELIMITER}{SLASH}").replace(SLASH, DELIMITER)
+
+ root_path = str(root_path)
+ if prefix in self.prefixes:
+ loader = self.prefixes[prefix]
+ if root_path in loader.searchpath:
+ return
+ logger.debug(f"Adding folder `{root_path}` with the prefix `{prefix}`")
+ loader.searchpath.append(root_path)
+ else:
+ logger.debug(f"Adding folder `{root_path}` with the prefix `{prefix}`")
+ self.prefixes[prefix] = jinja2.FileSystemLoader(root_path)
+
+ def add_module(self, module: t.Any, *, prefix: str | None = None) -> None:
+ """
+ Reads an absolute path from `module.components_path` and an optional prefix
+ from `module.prefix`, then calls `Catalog.add_folder(path, prefix)`.
+
+ The prefix can also be passed as an argument instead of being read from
+ the module.
+
+ This method exists to make it easy and consistent to have
+ components installable as Python libraries.
+
+ Arguments:
+
+ module:
+ A Python module.
+
+ prefix:
+ An optional prefix that replaces the one the module
+ might include.
+
+ """
+ mprefix = (
+ prefix if prefix is not None else getattr(module, "prefix", DEFAULT_PREFIX)
+ )
+ self.add_folder(module.components_path, prefix=mprefix)
+
+ def render(
+ self,
+ /,
+ __name: str,
+ *,
+ caller: "t.Callable | None" = None,
+ **kw,
+ ) -> str:
+ """
+ Resets the `collected_css` and `collected_js` lists and renders the
+ component and subcomponents inside of it.
+
+ This is the method you should call to render a parent component from a
+ view/controller in your app.
+
+ """
+ self.collected_css = []
+ self.collected_js = []
+ self._tmpl_globals = kw.pop("__globals", None)
+ return self.irender(__name, caller=caller, **kw)
+
+ def irender(
+ self,
+ /,
+ __name: str,
+ *,
+ caller: "t.Callable | None" = None,
+ **kw,
+ ) -> str:
+ """
+ Renders the component and subcomponents inside of it **without**
+ resetting the `collected_css` and `collected_js` lists.
+
+ This is the method you should call to render individual components that
+ are later inserted into a parent template.
+
+ """
+ content = (kw.pop("_content", kw.pop("__content", "")) or "").strip()
+ attrs = kw.pop("_attrs", kw.pop("__attrs", None)) or {}
+ file_ext = kw.pop("_file_ext", kw.pop("__file_ext", ""))
+ source = kw.pop("_source", kw.pop("__source", ""))
+
+ prefix, name = self._split_name(__name)
+ self.jinja_env.loader = self.prefixes[prefix]
+
+ if source:
+ logger.debug("Rendering from source %s", __name)
+ component = self._get_from_source(name=name, prefix=prefix, source=source)
+ elif self.use_cache:
+ logger.debug("Rendering from cache or file %s", __name)
+ component = self._get_from_cache(prefix=prefix, name=name, file_ext=file_ext)
+ else:
+ logger.debug("Rendering from file %s", __name)
+ component = self._get_from_file(prefix=prefix, name=name, file_ext=file_ext)
+
+ root_path = component.path.parent if component.path else None
+
+ for url in component.css:
+ if (
+ root_path
+ and self.fingerprint
+ and not url.startswith(("http://", "https://"))
+ ):
+ url = self._fingerprint(root_path, url)
+
+ if url not in self.collected_css:
+ self.collected_css.append(url)
+
+ for url in component.js:
+ if (
+ root_path
+ and self.fingerprint
+ and not url.startswith(("http://", "https://"))
+ ):
+ url = self._fingerprint(root_path, url)
+
+ if url not in self.collected_js:
+ self.collected_js.append(url)
+
+ attrs = attrs.as_dict if isinstance(attrs, HTMLAttrs) else attrs
+ attrs.update(kw)
+ kw = attrs
+ args, extra = component.filter_args(kw)
+ try:
+ args[ARGS_ATTRS] = HTMLAttrs(extra)
+ except Exception as exc:
+ raise InvalidArgument(
+ f"The arguments of the component <{component.name}>"
+ f"were parsed incorrectly as:\n {str(kw)}"
+ ) from exc
+
+ args[ARGS_CONTENT] = CallerWrapper(caller=caller, content=content)
+ return component.render(**args)
+
+ def get_middleware(
+ self,
+ application: t.Callable,
+ allowed_ext: "t.Iterable[str] | None" = ALLOWED_EXTENSIONS,
+ **kwargs,
+ ) -> ComponentsMiddleware:
+ """
+ Wraps you application with [Withenoise](https://whitenoise.readthedocs.io/),
+ a static file serving middleware.
+
+ Tecnically not neccesary if your components doesn't use static assets
+ or if you serve them by other means.
+
+ Arguments:
+
+ application:
+ A WSGI application
+
+ allowed_ext:
+ A list of file extensions the static middleware is allowed to read
+ and return. By default, is just ".css", ".js", and ".mjs".
+
+ """
+ logger.debug("Creating middleware")
+ middleware = ComponentsMiddleware(
+ application=application, allowed_ext=tuple(allowed_ext or []), **kwargs
+ )
+ for prefix, loader in self.prefixes.items():
+ url_prefix = get_url_prefix(prefix)
+ url = f"{self.root_url}{url_prefix}"
+ for root in loader.searchpath[::-1]:
+ middleware.add_files(root, url)
+
+ return middleware
+
+ def get_source(self, cname: str, file_ext: "tuple[str, ...] | str" = "") -> str:
+ """
+ A helper method that returns the source file of a component.
+ """
+ prefix, name = self._split_name(cname)
+ path, _ = self._get_component_path(prefix, name, file_ext=file_ext)
+ return path.read_text()
+
+ def render_assets(self) -> str:
+ """
+ Uses the `collected_css` and `collected_js` lists to generate
+ an HTML fragment with `<link rel="stylesheet" href="{url}">`
+ and `<script type="module" src="{url}"></script>` tags.
+
+ The URLs are prepended by `root_url` unless they begin with
+ "http://" or "https://".
+ """
+ html_css = []
+ for url in self.collected_css:
+ if not url.startswith(("http://", "https://")):
+ url = f"{self.root_url}{url}"
+ html_css.append(f'<link rel="stylesheet" href="{url}">')
+
+ html_js = []
+ for url in self.collected_js:
+ if not url.startswith(("http://", "https://")):
+ url = f"{self.root_url}{url}"
+ html_js.append(f'<script type="module" src="{url}"></script>')
+
+ return Markup("\n".join(html_css + html_js))
+
+ # Private
+
+ def _fingerprint(self, root: Path, filename: str) -> str:
+ relpath = Path(filename.lstrip(os.path.sep))
+ filepath = root / relpath
+ if not filepath.is_file():
+ return filename
+
+ stat = filepath.stat()
+ fingerprint = sha256(str(stat.st_mtime).encode()).hexdigest()
+
+ ext = "".join(relpath.suffixes)
+ stem = relpath.name.removesuffix(ext)
+ parent = str(relpath.parent)
+ parent = "" if parent == "." else f"{parent}/"
+
+ return f"{parent}{stem}-{fingerprint}{ext}"
+
+ def _get_from_source(self, *, name: str, prefix: str, source: str) -> Component:
+ tmpl = self.jinja_env.from_string(source, globals=self._tmpl_globals)
+ component = Component(name=name, prefix=prefix, source=source, tmpl=tmpl)
+ return component
+
+ def _get_from_cache(self, *, prefix: str, name: str, file_ext: str) -> Component:
+ key = f"{prefix}.{name}.{file_ext}"
+ cache = self._from_cache(key)
+ if cache:
+ component = Component.from_cache(
+ cache, auto_reload=self.auto_reload, globals=self._tmpl_globals
+ )
+ if component:
+ return component
+
+ logger.debug("Loading %s", key)
+ component = self._get_from_file(prefix=prefix, name=name, file_ext=file_ext)
+ self._to_cache(key, component)
+ return component
+
+ def _from_cache(self, key: str) -> dict[str, t.Any]:
+ if key not in self._cache:
+ return {}
+ cache = self._cache[key]
+ logger.debug("Loading from cache %s", key)
+ return cache
+
+ def _to_cache(self, key: str, component: Component) -> None:
+ self._cache[key] = component.serialize()
+
+ def _get_from_file(self, *, prefix: str, name: str, file_ext: str) -> Component:
+ path, tmpl_name = self._get_component_path(prefix, name, file_ext=file_ext)
+ component = Component(name=name, prefix=prefix, path=path)
+ component.tmpl = self.jinja_env.get_template(tmpl_name, globals=self._tmpl_globals)
+ return component
+
+ def _split_name(self, cname: str) -> tuple[str, str]:
+ cname = cname.strip().strip(DELIMITER)
+ if DELIMITER not in cname:
+ return DEFAULT_PREFIX, cname
+ for prefix in self.prefixes.keys():
+ _prefix = f"{prefix}{DELIMITER}"
+ if cname.startswith(_prefix):
+ return prefix, cname.removeprefix(_prefix)
+ return DEFAULT_PREFIX, cname
+
+ def _get_component_path(
+ self, prefix: str, name: str, file_ext: "tuple[str, ...] | str" = ""
+ ) -> tuple[Path, str]:
+ name = name.replace(DELIMITER, SLASH)
+ root_paths = self.prefixes[prefix].searchpath
+ name_dot = f"{name}."
+ file_ext = file_ext or self.file_ext
+
+ for root_path in root_paths:
+ for curr_folder, _, files in os.walk(
+ root_path, topdown=False, followlinks=True
+ ):
+ relfolder = os.path.relpath(curr_folder, root_path).strip(".")
+ if relfolder and not name_dot.startswith(relfolder):
+ continue
+
+ for filename in files:
+ if relfolder:
+ filepath = f"{relfolder}/{filename}"
+ else:
+ filepath = filename
+ if filepath.startswith(name_dot) and filepath.endswith(file_ext):
+ return Path(curr_folder) / filename, filepath
+
+ raise ComponentNotFound(
+ f"Unable to find a file named {name}{file_ext} "
+ f"or one following the pattern {name_dot}*{file_ext}"
+ )
+
+ def _render_attrs(self, attrs: dict[str, t.Any]) -> Markup:
+ html_attrs = []
+ for name, value in attrs.items():
+ if value != "":
+ html_attrs.append(f"{name}={value}")
+ else:
+ html_attrs.append(name)
+ return Markup(" ".join(html_attrs))
diff --git a/src/jinjax/component.py b/src/jinjax/component.py
new file mode 100644
index 0000000..948d170
--- /dev/null
+++ b/src/jinjax/component.py
@@ -0,0 +1,258 @@
+import ast
+import re
+import typing as t
+from keyword import iskeyword
+from pathlib import Path
+
+from jinja2 import Template
+from markupsafe import Markup
+
+from .exceptions import (
+ DuplicateDefDeclaration,
+ InvalidArgument,
+ MissingRequiredArgument,
+)
+from .utils import DELIMITER, get_url_prefix
+
+
+if t.TYPE_CHECKING:
+ from typing_extensions import Self
+
+RX_COMMA = re.compile(r"\s*,\s*")
+
+RX_ARGS_START = re.compile(r"{#-?\s*def\s+")
+RX_CSS_START = re.compile(r"{#-?\s*css\s+")
+RX_JS_START = re.compile(r"{#-?\s*js\s+")
+
+# This regexp matches the meta declarations (`{#def .. #}``, `{#css .. #}``,
+# and `{#js .. #}`) and regular Jinja comments AT THE BEGINNING of the components source.
+# You can also have comments inside the declarations.
+RX_META_HEADER = re.compile(r"^(\s*{#.*?#})+", re.DOTALL)
+
+# This regexep matches comments (everything after a `#`)
+# Used to remove them from inside meta declarations
+RX_INTER_COMMENTS = re.compile(r"\s*#[^\n]*")
+
+
+ALLOWED_NAMES_IN_EXPRESSION_VALUES = {
+ "len": len,
+ "max": max,
+ "min": min,
+ "pow": pow,
+ "sum": sum,
+ # Jinja allows using lowercase booleans, so we do it too for consistency
+ "false": False,
+ "true": True,
+}
+
+
+def eval_expression(input_string):
+ code = compile(input_string, "<string>", "eval")
+ for name in code.co_names:
+ if name not in ALLOWED_NAMES_IN_EXPRESSION_VALUES:
+ raise InvalidArgument(f"Use of {name} not allowed")
+ try:
+ return eval(code, {"__builtins__": {}}, ALLOWED_NAMES_IN_EXPRESSION_VALUES)
+ except NameError as err:
+ raise InvalidArgument(err) from err
+
+
+def is_valid_variable_name(name):
+ return name.isidentifier() and not iskeyword(name)
+
+
+class Component:
+ """Internal class
+ """
+ __slots__ = (
+ "name",
+ "prefix",
+ "url_prefix",
+ "required",
+ "optional",
+ "css",
+ "js",
+ "path",
+ "mtime",
+ "tmpl",
+ )
+
+ def __init__(
+ self,
+ *,
+ name: str,
+ prefix: str = "",
+ url_prefix: str = "",
+ source: str = "",
+ mtime: float = 0,
+ tmpl: "Template | None" = None,
+ path: "Path | None" = None,
+ ) -> None:
+ self.name = name
+ self.prefix = prefix
+ self.url_prefix = url_prefix or get_url_prefix(prefix)
+ self.required: list[str] = []
+ self.optional: dict[str, t.Any] = {}
+ self.css: list[str] = []
+ self.js: list[str] = []
+
+ if path is not None:
+ source = source or path.read_text()
+ mtime = mtime or path.stat().st_mtime
+ if source:
+ self.load_metadata(source)
+
+ if path is not None:
+ default_name = self.name.replace(DELIMITER, "/")
+
+ default_css = f"{default_name}.css"
+ if (path.with_suffix(".css")).is_file():
+ self.css.extend(self.parse_files_expr(default_css))
+
+ default_js = f"{default_name}.js"
+ if (path.with_suffix(".js")).is_file():
+ self.js.extend(self.parse_files_expr(default_js))
+
+ self.path = path
+ self.mtime = mtime
+ self.tmpl = tmpl
+
+ @classmethod
+ def from_cache(
+ cls,
+ cache: dict[str, t.Any],
+ auto_reload: bool = True,
+ globals: "t.MutableMapping[str, t.Any] | None" = None,
+ ) -> "Self | None":
+ path = cache["path"]
+ mtime = cache["mtime"]
+
+ if auto_reload:
+ if not path.is_file() or path.stat().st_mtime != mtime:
+ return None
+
+ self = cls(name=cache["name"])
+ self.prefix = cache["prefix"]
+ self.url_prefix = cache["url_prefix"]
+ self.required = cache["required"]
+ self.optional = cache["optional"]
+ self.css = cache["css"]
+ self.js = cache["js"]
+ self.path = path
+ self.mtime = cache["mtime"]
+ self.tmpl = cache["tmpl"]
+
+ if globals:
+ # updating the template globals, does not affect the environment globals
+ self.tmpl.globals.update(globals)
+
+ return self
+
+ def serialize(self) -> dict[str, t.Any]:
+ return {
+ "name": self.name,
+ "prefix": self.prefix,
+ "url_prefix": self.url_prefix,
+ "required": self.required,
+ "optional": self.optional,
+ "css": self.css,
+ "js": self.js,
+ "path": self.path,
+ "mtime": self.mtime,
+ "tmpl": self.tmpl,
+ }
+
+ def load_metadata(self, source: str) -> None:
+ match = RX_META_HEADER.match(source)
+ if not match:
+ return
+
+ header = match.group(0)
+ # Reversed because I will use `header.pop()`
+ header = header.split("#}")[:-1][::-1]
+ def_found = False
+
+ while header:
+ item = header.pop().strip(" -\n")
+
+ expr = self.read_metadata_item(item, RX_ARGS_START)
+ if expr:
+ if def_found:
+ raise DuplicateDefDeclaration(self.name)
+ self.required, self.optional = self.parse_args_expr(expr)
+ def_found = True
+ continue
+
+ expr = self.read_metadata_item(item, RX_CSS_START)
+ if expr:
+ expr = RX_INTER_COMMENTS.sub("", expr).replace("\n", " ")
+ self.css = [*self.css, *self.parse_files_expr(expr)]
+ continue
+
+ expr = self.read_metadata_item(item, RX_JS_START)
+ if expr:
+ expr = RX_INTER_COMMENTS.sub("", expr).replace("\n", " ")
+ self.js = [*self.js, *self.parse_files_expr(expr)]
+ continue
+
+ def read_metadata_item(self, source: str, rx_start: re.Pattern) -> str:
+ start = rx_start.match(source)
+ if not start:
+ return ""
+ return source[start.end():].strip()
+
+ def parse_args_expr(self, expr: str) -> tuple[list[str], dict[str, t.Any]]:
+ expr = expr.strip(" *,/")
+ required = []
+ optional = {}
+
+ try:
+ p = ast.parse(f"def component(*,\n{expr}\n): pass")
+ except SyntaxError as err:
+ raise InvalidArgument(err) from err
+
+ args = p.body[0].args # type: ignore
+ arg_names = [arg.arg for arg in args.kwonlyargs]
+ for name, value in zip(arg_names, args.kw_defaults): # noqa: B905
+ if value is None:
+ required.append(name)
+ continue
+ expr = ast.unparse(value)
+ optional[name] = eval_expression(expr)
+
+ return required, optional
+
+ def parse_files_expr(self, expr: str) -> list[str]:
+ files = []
+ for url in RX_COMMA.split(expr):
+ url = url.strip("\"'").rstrip("/")
+ if not url:
+ continue
+ if url.startswith(("/", "http://", "https://")):
+ files.append(url)
+ else:
+ files.append(f"{self.url_prefix}{url}")
+ return files
+
+ def filter_args(
+ self, kw: dict[str, t.Any]
+ ) -> tuple[dict[str, t.Any], dict[str, t.Any]]:
+ args = {}
+
+ for key in self.required:
+ if key not in kw:
+ raise MissingRequiredArgument(self.name, key)
+ args[key] = kw.pop(key)
+
+ for key in self.optional:
+ args[key] = kw.pop(key, self.optional[key])
+ extra = kw.copy()
+ return args, extra
+
+ def render(self, **kwargs):
+ assert self.tmpl, f"Component {self.name} has no template"
+ html = self.tmpl.render(**kwargs).strip()
+ return Markup(html)
+
+ def __repr__(self) -> str:
+ return f'<Component "{self.name}">'
diff --git a/src/jinjax/exceptions.py b/src/jinjax/exceptions.py
new file mode 100644
index 0000000..b1fd97f
--- /dev/null
+++ b/src/jinjax/exceptions.py
@@ -0,0 +1,37 @@
+class ComponentNotFound(Exception):
+ """
+ Raised when JinjaX can't find a component by name in none of the
+ added folders, probably because of a typo.
+ """
+
+ def __init__(self, name: str) -> None:
+ msg = f"File with pattern `{name}` not found"
+ super().__init__(msg)
+
+
+class MissingRequiredArgument(Exception):
+ """
+ Raised when a component is used/invoked without passing one or more
+ of its required arguments (those without a default value).
+ """
+
+ def __init__(self, component: str, arg: str) -> None:
+ msg = f"`{component}` component requires a `{arg}` argument"
+ super().__init__(msg)
+
+
+class DuplicateDefDeclaration(Exception):
+ """
+ Raised when a component has more then one `{#def ... #}` declarations.
+ """
+
+ def __init__(self, component: str) -> None:
+ msg = "`" + str(component) + "` has two `{#def ... #}` declarations"
+ super().__init__(msg)
+
+
+class InvalidArgument(Exception):
+ """
+ Raised when the arguments passed to the component cannot be parsed
+ by JinjaX because of an invalid syntax.
+ """
diff --git a/src/jinjax/html_attrs.py b/src/jinjax/html_attrs.py
new file mode 100644
index 0000000..8b4bd35
--- /dev/null
+++ b/src/jinjax/html_attrs.py
@@ -0,0 +1,348 @@
+import re
+import typing as t
+from collections import UserString
+from functools import cached_property
+
+from markupsafe import Markup
+
+
+CLASS_KEY = "class"
+CLASS_ALT_KEY = "classes"
+CLASS_KEYS = (CLASS_KEY, CLASS_ALT_KEY)
+
+
+def split(ssl: str) -> list[str]:
+ return re.split(r"\s+", ssl.strip())
+
+
+def quote(text: str) -> str:
+ if '"' in text:
+ if "'" in text:
+ text = text.replace('"', "&quot;")
+ return f'"{text}"'
+ else:
+ return f"'{text}'"
+
+ return f'"{text}"'
+
+
+class LazyString(UserString):
+ """
+ Behave like regular strings, but the actual casting of the initial value
+ is deferred until the value is actually required.
+ """
+
+ __slots__ = ("_seq",)
+
+ def __init__(self, seq):
+ self._seq = seq
+
+ @cached_property
+ def data(self): # type: ignore
+ return str(self._seq)
+
+
+class HTMLAttrs:
+ """
+ Contains all the HTML attributes/properties (a property is an
+ attribute without a value) passed to a component but that weren't
+ in the declared attributes list.
+
+ For HTML classes you can use the name "classes" (instead of "class")
+ if you need to.
+
+ **NOTE**: The string values passed to this class, are not cast to `str` until
+ the string representation is actually needed, for example when
+ `attrs.render()` is invoked.
+
+ """
+
+ def __init__(self, attrs: "dict[str, t.Any| LazyString]") -> None:
+ attributes: "dict[str, str | LazyString]" = {}
+ properties: set[str] = set()
+
+ class_names = split(" ".join([
+ str(attrs.pop(CLASS_KEY, "")),
+ str(attrs.get(CLASS_ALT_KEY, "")),
+ ]))
+ self.__classes = {name for name in class_names if name}
+
+ for name, value in attrs.items():
+ name = name.replace("_", "-")
+ if value is True:
+ properties.add(name)
+ elif value is not False and value is not None:
+ attributes[name] = LazyString(value)
+
+ self.__attributes = attributes
+ self.__properties = properties
+
+ @property
+ def classes(self) -> str:
+ """
+ All the HTML classes alphabetically sorted and separated by a space.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({"class": "italic bold bg-blue wide abcde"})
+ attrs.set(class="bold text-white")
+ print(attrs.classes)
+ abcde bg-blue bold italic text-white wide
+ ```
+
+ """
+ return " ".join(sorted((self.__classes)))
+
+ @property
+ def as_dict(self) -> dict[str, t.Any]:
+ """
+ An ordered dict of all the attributes and properties, both
+ sorted by name before join.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({
+ "class": "lorem ipsum",
+ "data_test": True,
+ "hidden": True,
+ "aria_label": "hello",
+ "id": "world",
+ })
+ attrs.as_dict
+ {
+ "aria_label": "hello",
+ "class": "ipsum lorem",
+ "id": "world",
+ "data_test": True,
+ "hidden": True
+ }
+ ```
+
+ """
+ attributes = self.__attributes.copy()
+ classes = self.classes
+ if classes:
+ attributes[CLASS_KEY] = classes
+
+ out: dict[str, t.Any] = dict(sorted(attributes.items()))
+ for name in sorted((self.__properties)):
+ out[name] = True
+ return out
+
+ def __getitem__(self, name: str) -> t.Any:
+ return self.get(name)
+
+ def __delitem__(self, name: str) -> None:
+ self._remove(name)
+
+ def __str__(self) -> str:
+ return str(self.as_dict)
+
+ def set(self, **kw) -> None:
+ """
+ Sets an attribute or property
+
+ - Pass a name and a value to set an attribute (e.g. `type="text"`)
+ - Use `True` as a value to set a property (e.g. `disabled`)
+ - Use `False` to remove an attribute or property
+ - If the attribute is "class", the new classes are appended to
+ the old ones (if not repeated) instead of replacing them.
+ - The underscores in the names will be translated automatically to dashes,
+ so `aria_selected` becomes the attribute `aria-selected`.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({"secret": "qwertyuiop"})
+ attrs.set(secret=False)
+ attrs.as_dict
+ {}
+
+ attrs.set(unknown=False, lorem="ipsum", count=42, data_good=True)
+ attrs.as_dict
+ {"count":42, "lorem":"ipsum", "data_good": True}
+
+ attrs = HTMLAttrs({"class": "b c a"})
+ attrs.set(class="c b f d e")
+ attrs.as_dict
+ {"class": "a b c d e f"}
+ ```
+
+ """
+ for name, value in kw.items():
+ name = name.replace("_", "-")
+ if value is False or value is None:
+ self._remove(name)
+ continue
+
+ if name in CLASS_KEYS:
+ self.add_class(value)
+ elif value is True:
+ self.__properties.add(name)
+ else:
+ self.__attributes[name] = value
+
+ def setdefault(self, **kw) -> None:
+ """
+ Adds an attribute, but only if it's not already present.
+
+ The underscores in the names will be translated automatically to dashes,
+ so `aria_selected` becomes the attribute `aria-selected`.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({"lorem": "ipsum"})
+ attrs.setdefault(tabindex=0, lorem="meh")
+ attrs.as_dict
+ # "tabindex" changed but "lorem" didn't
+ {"lorem": "ipsum", tabindex: 0}
+ ```
+
+ """
+ for name, value in kw.items():
+ if value in (True, False, None):
+ continue
+
+ if name in CLASS_KEYS:
+ if not self.__classes:
+ self.add_class(value)
+
+ name = name.replace("_", "-")
+ if name not in self.__attributes:
+ self.set(**{name: value})
+
+ def add_class(self, *values: str) -> None:
+ """
+ Adds one or more classes to the list of classes, if not already present.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({"class": "a b c"})
+ attrs.add_class("c", "d")
+ attrs.as_dict
+ {"class": "a b c d"}
+ ```
+
+ """
+ for names in values:
+ for name in split(names):
+ self.__classes.add(name)
+
+ def remove_class(self, *names: str) -> None:
+ """
+ Removes one or more classes from the list of classes.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({"class": "a b c"})
+ attrs.remove_class("c", "d")
+ attrs.as_dict
+ {"class": "a b"}
+ ```
+
+ """
+ for name in names:
+ self.__classes.remove(name)
+
+ def get(self, name: str, default: t.Any = None) -> t.Any:
+ """
+ Returns the value of the attribute or property,
+ or the default value if it doesn't exists.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({"lorem": "ipsum", "hidden": True})
+
+ attrs.get("lorem", defaut="bar")
+ 'ipsum'
+
+ attrs.get("foo")
+ None
+
+ attrs.get("foo", defaut="bar")
+ 'bar'
+
+ attrs.get("hidden")
+ True
+ ```
+
+ """
+ name = name.replace("_", "-")
+ if name in CLASS_KEYS:
+ return self.classes
+ if name in self.__attributes:
+ return self.__attributes[name]
+ if name in self.__properties:
+ return True
+ return default
+
+ def render(self, **kw) -> str:
+ """
+ Renders the attributes and properties as a string.
+
+ Any arguments you use with this function are merged with the existing
+ attibutes/properties by the same rules as the `HTMLAttrs.set()` function:
+
+ - Pass a name and a value to set an attribute (e.g. `type="text"`)
+ - Use `True` as a value to set a property (e.g. `disabled`)
+ - Use `False` to remove an attribute or property
+ - If the attribute is "class", the new classes are appended to
+ the old ones (if not repeated) instead of replacing them.
+ - The underscores in the names will be translated automatically to dashes,
+ so `aria_selected` becomes the attribute `aria-selected`.
+
+ To provide consistent output, the attributes and properties
+ are sorted by name and rendered like this:
+ `<sorted attributes> + <sorted properties>`.
+
+ Example:
+
+ ```python
+ attrs = HTMLAttrs({"class": "ipsum", "data_good": True, "width": 42})
+
+ attrs.render()
+ 'class="ipsum" width="42" data-good'
+
+ attrs.render(class="abc", data_good=False, tabindex=0)
+ 'class="abc ipsum" width="42" tabindex="0"'
+ ```
+
+ """
+ if kw:
+ self.set(**kw)
+
+ attributes = self.__attributes.copy()
+
+ classes = self.classes
+ if classes:
+ attributes[CLASS_KEY] = classes
+
+ attributes = dict(sorted(attributes.items()))
+ properties = sorted((self.__properties))
+
+ html_attrs = [
+ f"{name}={quote(str(value))}"
+ for name, value in attributes.items()
+ ]
+ html_attrs.extend(properties)
+
+ return Markup(" ".join(html_attrs))
+
+ # Private
+
+ def _remove(self, name: str) -> None:
+ """
+ Removes an attribute or property.
+ """
+ if name in CLASS_KEYS:
+ self.__classes = set()
+ if name in self.__attributes:
+ del self.__attributes[name]
+ if name in self.__properties:
+ self.__properties.remove(name)
diff --git a/src/jinjax/jinjax.py b/src/jinjax/jinjax.py
new file mode 100644
index 0000000..b7a9783
--- /dev/null
+++ b/src/jinjax/jinjax.py
@@ -0,0 +1,161 @@
+import re
+import typing as t
+from uuid import uuid4
+
+from jinja2.exceptions import TemplateSyntaxError
+from jinja2.ext import Extension
+from jinja2.filters import do_forceescape
+
+from .utils import logger
+
+
+RENDER_CMD = "catalog.irender"
+BLOCK_CALL = '{% call(_slot) [CMD]("[TAG]"[ATTRS]) -%}[CONTENT]{%- endcall %}'
+BLOCK_CALL = BLOCK_CALL.replace("[CMD]", RENDER_CMD)
+INLINE_CALL = '{{ [CMD]("[TAG]"[ATTRS]) }}'
+INLINE_CALL = INLINE_CALL.replace("[CMD]", RENDER_CMD)
+
+re_raw = r"\{%-?\s*raw\s*-?%\}.+?\{%-?\s*endraw\s*-?%\}"
+RX_RAW = re.compile(re_raw, re.DOTALL)
+
+re_tag_name = r"([0-9A-Za-z_-]+\.)*[A-Z][0-9A-Za-z_-]*"
+re_raw_attrs = r"(?P<attrs>[^\>]*)"
+re_tag = rf"<(?P<tag>{re_tag_name}){re_raw_attrs}\s*/?>"
+RX_TAG = re.compile(re_tag)
+
+re_attr_name = r""
+re_equal = r""
+re_attr = r"""
+(?P<name>[a-zA-Z@:$_][a-zA-Z@:$_0-9-]*)
+(?:
+ \s*=\s*
+ (?P<value>".*?"|'.*?'|\{\{.*?\}\})
+)?
+(?:\s+|/|"|$)
+"""
+RX_ATTR = re.compile(re_attr, re.VERBOSE | re.DOTALL)
+
+
+class JinjaX(Extension):
+ def preprocess(
+ self,
+ source: str,
+ name: t.Optional[str] = None,
+ filename: t.Optional[str] = None,
+ ) -> str:
+ self.__raw_blocks = {}
+ self._name = name
+ self._filename = filename
+ source = self._replace_raw_blocks(source)
+ source = self._process_tags(source)
+ source = self._restore_raw_blocks(source)
+ self.__raw_blocks = {}
+ return source
+
+ def _replace_raw_blocks(self, source: str) -> str:
+ while True:
+ match = RX_RAW.search(source)
+ if not match:
+ break
+ start, end = match.span(0)
+ repl = self._replace_raw_block(match)
+ source = f"{source[:start]}{repl}{source[end:]}"
+
+ return source
+
+ def _replace_raw_block(self, match: re.Match) -> str:
+ uid = f"--RAW-{uuid4().hex}--"
+ self.__raw_blocks[uid] = do_forceescape(match.group(0))
+ return uid
+
+ def _restore_raw_blocks(self, source: str) -> str:
+ for uid, code in self.__raw_blocks.items():
+ source = source.replace(uid, code)
+ return source
+
+ def _process_tags(self, source: str) -> str:
+ while True:
+ match = RX_TAG.search(source)
+ if not match:
+ break
+ source = self._process_tag(source, match)
+ return source
+
+ def _process_tag(self, source: str, match: re.Match) -> str:
+ start, end = match.span(0)
+ tag = match.group("tag")
+ attrs = (match.group("attrs") or "").strip()
+ inline = match.group(0).endswith("/>")
+ lineno = source[:start].count("\n") + 1
+
+ logger.debug(f"{tag} {attrs} {'inline' if not inline else ''}")
+ if inline:
+ content = ""
+ else:
+ end_tag = f"</{tag}>"
+ index = source.find(end_tag, end, None)
+ if index == -1:
+ raise TemplateSyntaxError(
+ message=f"Unclosed component {match.group(0)}",
+ lineno=lineno,
+ name=self._name,
+ filename=self._filename
+ )
+ content = source[end:index]
+ end = index + len(end_tag)
+
+ attrs_list = self._parse_attrs(attrs)
+ repl = self._build_call(tag, attrs_list, content)
+
+ return f"{source[:start]}{repl}{source[end:]}"
+
+ def _parse_attrs(self, attrs: str) -> list[tuple[str, str]]:
+ attrs = attrs.replace("\n", " ").strip()
+ if not attrs:
+ return []
+ return RX_ATTR.findall(attrs)
+
+ def _build_call(
+ self,
+ tag: str,
+ attrs_list: list[tuple[str, str]],
+ content: str = "",
+ ) -> str:
+ logger.debug(f"{tag} {attrs_list} {'inline' if not content else ''}")
+ attrs = []
+ for name, value in attrs_list:
+ name = name.strip().replace("-", "_")
+ value = value.strip()
+
+ if not value:
+ name = name.lstrip(":")
+ attrs.append(f'"{name}"=True')
+ else:
+ # vue-like syntax
+ if (
+ name[0] == ":"
+ and value[0] in ("\"'")
+ and value[-1] in ("\"'")
+ ):
+ value = value[1:-1].strip()
+
+ # double curly braces syntax
+ if value[:2] == "{{" and value[-2:] == "}}":
+ value = value[2:-2].strip()
+
+ name = name.lstrip(":")
+ attrs.append(f'"{name}"={value}')
+
+ str_attrs = "**{" + ", ".join([a.replace("=", ":", 1) for a in attrs]) + "}"
+ if str_attrs:
+ str_attrs = f", {str_attrs}"
+
+ if not content:
+ call = INLINE_CALL.replace("[TAG]", tag).replace("[ATTRS]", str_attrs)
+ else:
+ call = (
+ BLOCK_CALL.replace("[TAG]", tag)
+ .replace("[ATTRS]", str_attrs)
+ .replace("[CONTENT]", content)
+ )
+ return call
diff --git a/src/jinjax/middleware.py b/src/jinjax/middleware.py
new file mode 100644
index 0000000..7c8acd8
--- /dev/null
+++ b/src/jinjax/middleware.py
@@ -0,0 +1,39 @@
+import re
+import typing as t
+from pathlib import Path
+
+from whitenoise import WhiteNoise
+from whitenoise.responders import Redirect, StaticFile
+
+
+RX_FINGERPRINT = re.compile("(.*)-([abcdef0-9]{64})")
+
+
+class ComponentsMiddleware(WhiteNoise):
+ """WSGI middleware for serving components assets"""
+ allowed_ext: tuple[str, ...]
+
+ def __init__(self, **kwargs) -> None:
+ self.allowed_ext = kwargs.pop("allowed_ext", ())
+ super().__init__(**kwargs)
+
+ def find_file(self, url: str) -> "StaticFile | Redirect | None":
+
+ if self.allowed_ext and not url.endswith(self.allowed_ext):
+ return None
+
+ # Ignore the fingerprint in the filename
+ # since is only for managing the cache in the client
+ relpath = Path(url)
+ ext = "".join(relpath.suffixes)
+ stem = relpath.name.removesuffix(ext)
+ fingerprinted = RX_FINGERPRINT.match(stem)
+ if fingerprinted:
+ stem = fingerprinted.group(1)
+ relpath = relpath.with_name(f"{stem}{ext}")
+
+ return super().find_file(str(relpath))
+
+ def add_file_to_dictionary(self, url: str, path: str, stat_cache: t.Any = None) -> None:
+ if not self.allowed_ext or url.endswith(self.allowed_ext):
+ super().add_file_to_dictionary(url, path, stat_cache)
diff --git a/src/jinjax/py.typed b/src/jinjax/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/jinjax/py.typed
diff --git a/src/jinjax/utils.py b/src/jinjax/utils.py
new file mode 100644
index 0000000..ff1ca4f
--- /dev/null
+++ b/src/jinjax/utils.py
@@ -0,0 +1,16 @@
+import logging
+
+
+logger = logging.getLogger("jinjax")
+
+DELIMITER = "."
+SLASH = "/"
+
+
+def get_url_prefix(prefix: str) -> str:
+ url_prefix = (
+ prefix.strip().strip(f"{DELIMITER}{SLASH}").replace(DELIMITER, SLASH)
+ )
+ if url_prefix:
+ url_prefix = f"{url_prefix}{SLASH}"
+ return url_prefix