EmilStenstrom / django-components

Create simple reusable template components in Django.
MIT License
1.02k stars 66 forks source link

Allow users to customize component tags + reintroduce component "inline" block #527

Open JuroOravec opened 2 weeks ago

JuroOravec commented 2 weeks ago

Another week, another feature! @EmilStenstrom Let me know what you think about this one.

Description

When I was considering libraries like django_components, I felt that the way how components are declared in the template is too lengthy:

{% component "my_comp" ... %}
{% endcomponent %}

and the appeal of django-slippers or django-web-components was that they had a terser syntax, e.g.:

{% my_comp %}
{% endmy_comp %}

OR 

{% #my_comp %}
{% /my_comp %}

This has, I think, two important considerations:

  1. For an library like django_components, it doesn't add anything useful if we changed how the component tags are rendered, from one opinionated approach to another.
  2. However, on the level of the broader community, there are some people who prefer shorter syntax. And they may choose other libraries like django-slippers/django-web-components, but those libraries may not have many of our features. So IMO it's not a great "offering" if one has to choose between e.g. longer syntax + autodiscovery (django_components) vs shorter syntax but with no autodiscovery (django-slippers).

In django-web-components, they did a really interesting solution to this, which is that they allow users to decide what tags they want to use for the components. See Component tag formatter.

In their case, one can configure the used template tags as so:

class ComponentTagFormatter:
    def format_block_start_tag(self, name):
        return f"#{name}"

    def format_block_end_tag(self, name):
        return f"/{name}"

    def format_inline_tag(self, name):
        return name

# inside your settings
WEB_COMPONENTS = {
    "DEFAULT_COMPONENT_TAG_FORMATTER": "path.to.your.ComponentTagFormatter",
}

Which then allows them to call components inside the template as so:

{% #my_comp %}
{% /my_comp %}

API

TagFormatter class

I gave this a try. For django_components, this had to be a bit modified, because we use the component tag, which is shared by all components. So in our case it's not sufficient to know only about the tag, but we need to also get a second piece of information (the component name) to know how to render it. Conversely, if we used tags like #my_comp, then we'd have the component name already in the tag.

For this reason, I had to add also parse_block_start_tag and parse_inline_tag, where the user would have the chance to parse tag inputs, and extract info that specifies what component it is.

See TagFormatter API ```py class TagFormatter(): def format_block_start_tag(self, name: str) -> str: """Formats the start tag of a block component.""" ... def format_block_end_tag(self, name: str) -> str: """Formats the end tag of a block component.""" ... def format_inline_tag(self, name: str) -> str: """Formats the start tag of an inline component.""" ... def parse_block_start_tag(self, tokens: List[str]) -> Tuple[str, List[str]]: """ Given the tokens (words) of a component start tag, this function extracts the component name from the tokens list, and returns a tuple of `(component_name, component_input)`. Example: Given a component declarations: `{% component "my_comp" key=val key2=val2 %}` This function receives a list of tokens `['component', '"my_comp"', 'key=val', 'key2=val2']` `component` is the tag name, which we drop. `"my_comp"` is the component name, but we must remove the extra quotes. And we pass remaining tokens unmodified, as that's the input to the component. So in the end, we return a tuple: `('my_comp', ['key=val', 'key2=val2'])` """ ... def parse_inline_tag(self, tokens: List[str]) -> Tuple[str, List[str]]: """Same as `parse_block_start_tag`, but for inline components.""" ... ```

Here's an example how we'd use the TagFormatter to use the components with the {% component %} (as it is now):

See ComponentTagFormatter ```py class ComponentTagFormatter(TagFormatterABC): def format_block_start_tag(self, name: str) -> str: return "component" def format_block_end_tag(self, name: str) -> str: return "endcomponent" def format_inline_tag(self, name: str) -> str: return "#component" def parse_block_start_tag(self, tokens: List[str]) -> Tuple[str, List[str]]: if tokens[0] != "component": raise TemplateSyntaxError(f"Component block start tag parser received tag '{tokens[0]}', expected 'component'") return self._parse_start_or_inline_tag(tokens) def parse_inline_tag(self, tokens: List[str]) -> Tuple[str, List[str]]: if tokens[0] != "#component": raise TemplateSyntaxError(f"Component block start tag parser received tag '{tokens[0]}', expected '#component'") return self._parse_start_or_inline_tag(tokens) # _parse_start_or_inline_tag() omitted for clarityy ```

tag_formatter setting

As shown before, in django-web-components, they set this TagFormatter as app settings. I think it makes sense to define it there, so that ALL components are declared the same way throughout the app.

They define the tag formatter as an import path. Again, I think this makes sense, as this approach is used a lot in Django's settings.

So in our case it'd look like this:

# settings.py
COMPONENTS = {
    "tag_formatter": "path.to.your.ComponentTagFormatter",
}

However, to make things easier for when working in tests, and to get errors when the import path changes, I allowed to also pass the TagFormatter class directly:

from django_component.tag_formatter import ComponentTagFormatter

# settings.py
COMPONENTS = {
    "tag_formatter": ComponentTagFormatter,
}

Pre-defined TagFormatters for the original and "short" forms

To make it easy for users, I've created two TagFormatter.

  1. ComponentTagFormatter as seen above. This is the default tag formatter to keep the original behavior intact
  2. ShorthandTagFormatter - Tag formatter that behaves like in django-web-components, so instead of {% component "my_comp" %}, you use {% my_comp %}, and instead of {% endcomponent %}, you use {% endmy_comp %}.

"Inline" components

This means that this feature would again introduce the "inline" component tags, meaning {% component %} tags without {% endcomponent %}, and so with no slots fill. E.g.:

{% #component "my_comp" %}

Instead of

{% component "my_comp" %}{% endcomponent %}
EmilStenstrom commented 2 weeks ago

I think this is a really nice addition to the library!

JuroOravec commented 1 week ago

Awesome!

Turns out that there is more work required to get to this feature:

  1. I had to update the ComponentRegistry too, so it works with the assumption that each component may have a different template tag.
  2. There were issues with circular imports, so I moved the autocomplete logic into it's own file, and made django_components/__init__.py the API's entrypoint. So users will import from django_components, instead of django_components.component, as it is currently. And it allows us to internally import from django_components.component.

To slowly move towards https://github.com/EmilStenstrom/django-components/issues/473, I am also documenting the registry and the autoimport files/features. So before we get to the "tag formatter" feature, there will be at least these 3 PRs:

  1. Move autocomplete to own file and document
  2. Update component registry file and document
  3. Change the public API entrypoint from django_components.component to django_components
EmilStenstrom commented 1 week ago

@JuroOravec Sounds awesome. Very happy to review many smaller PRs instead of 1000 line ones :)