Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
JuroOravec opened this issue Jun 17, 2024 · 3 comments

Comments

@JuroOravec
Copy link
Collaborator

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
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
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
Copy link
Owner

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

@JuroOravec
Copy link
Collaborator Author

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 #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
Copy link
Owner

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants