"""All code related to management of component dependencies (JS and CSS scripts)""" import base64 import json import re from hashlib import md5 from typing import ( TYPE_CHECKING, Dict, List, Literal, Mapping, Optional, Sequence, Set, Tuple, Type, TypeVar, Union, cast, ) from django.forms import Media from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed, HttpResponseNotFound from django.template import Context, TemplateSyntaxError from django.templatetags.static import static from django.urls import path, reverse from django.utils.safestring import SafeString, mark_safe from djc_core_html_parser import set_html_attributes from django_components.cache import get_component_media_cache from django_components.constants import COMP_ID_LENGTH from django_components.node import BaseNode from django_components.util.misc import is_nonempty_str if TYPE_CHECKING: from django_components.component import Component ScriptType = Literal["css", "js"] DependenciesStrategy = Literal["document", "fragment", "simple", "prepend", "append", "ignore"] """ Type for the available strategies for rendering JS and CSS dependencies. Read more about the [dependencies strategies](../../concepts/advanced/rendering_js_css). """ DEPS_STRATEGIES = ("document", "fragment", "simple", "prepend", "append", "ignore") ######################################################### # 1. Cache the inlined component JS and CSS scripts (`Component.js` and `Component.css`). # # To support HTML fragments, when a fragment is loaded on a page, # we on-demand request the JS and CSS files of the components that are # referenced in the fragment. # # Thus, we need to persist the JS and CSS files across requests. These are then accessed # via `cached_script_view` endpoint. ######################################################### # Generate keys like # `__components:MyButton_a78y37:js:df7c6d10` # `__components:MyButton_a78y37:css` def _gen_cache_key( comp_cls_id: str, script_type: ScriptType, input_hash: Optional[str], ) -> str: if input_hash: return f"__components:{comp_cls_id}:{script_type}:{input_hash}" else: return f"__components:{comp_cls_id}:{script_type}" def _is_script_in_cache( comp_cls: Type["Component"], script_type: ScriptType, input_hash: Optional[str], ) -> bool: cache_key = _gen_cache_key(comp_cls.class_id, script_type, input_hash) cache = get_component_media_cache() return cache.has_key(cache_key) def _cache_script( comp_cls: Type["Component"], script: str, script_type: ScriptType, input_hash: Optional[str], ) -> None: """ Given a component and it's inlined JS or CSS, store the JS/CSS in a cache, so it can be retrieved via URL endpoint. """ # E.g. `__components:MyButton:js:df7c6d10` if script_type in ("js", "css"): cache_key = _gen_cache_key(comp_cls.class_id, script_type, input_hash) else: raise ValueError(f"Unexpected script_type '{script_type}'") # NOTE: By setting the script in the cache, we will be able to retrieve it # via the endpoint, e.g. when we make a request to `/components/cache/MyComp_ab0c2d.js`. cache = get_component_media_cache() cache.set(cache_key, script.strip()) def cache_component_js(comp_cls: Type["Component"]) -> None: """ Cache the content from `Component.js`. This is the common JS that's shared among all instances of the same component. So even if the component is rendered multiple times, this JS is loaded only once. """ if not comp_cls.js or not is_nonempty_str(comp_cls.js) or _is_script_in_cache(comp_cls, "js", None): return None _cache_script( comp_cls=comp_cls, script=comp_cls.js, script_type="js", input_hash=None, ) # NOTE: In CSS, we link the CSS vars to the component via a stylesheet that defines # the CSS vars under `[data-djc-css-a1b2c3]`. Because of this we define the variables # separately from the rest of the CSS definition. # # We use conceptually similar approach for JS, except in JS we have to manually associate # the JS variables ("stylesheet") with the target HTML element ("component"). # # It involves 3 steps: # 1. Register the common logic (equivalent to registering common CSS). # with `Components.manager.registerComponent`. # 2. Register the unique set of JS variables (equivalent to defining CSS vars) # with `Components.manager.registerComponentData`. # 3. Actually run a component's JS instance with `Components.manager.callComponent`, # specifying the components HTML elements with `component_id`, and JS vars with `input_hash`. def cache_component_js_vars(comp_cls: Type["Component"], js_vars: Mapping) -> Optional[str]: if not is_nonempty_str(comp_cls.js): return None # The hash for the file that holds the JS variables is derived from the variables themselves. json_data = json.dumps(js_vars) input_hash = md5(json_data.encode()).hexdigest()[0:6] # Generate and cache a JS script that contains the JS variables. if not _is_script_in_cache(comp_cls, "js", input_hash): _cache_script( comp_cls=comp_cls, script="", # TODO - enable JS and CSS vars script_type="js", input_hash=input_hash, ) return input_hash def wrap_component_js(comp_cls: Type["Component"], content: str) -> str: if "' end tag. " "This is not allowed, as it would break the HTML." ) return f"" def cache_component_css(comp_cls: Type["Component"]) -> None: """ Cache the content from `Component.css`. This is the common CSS that's shared among all instances of the same component. So even if the component is rendered multiple times, this CSS is loaded only once. """ if not comp_cls.css or not is_nonempty_str(comp_cls.css) or _is_script_in_cache(comp_cls, "css", None): return None _cache_script( comp_cls=comp_cls, script=comp_cls.css, script_type="css", input_hash=None, ) # NOTE: In CSS, we link the CSS vars to the component via a stylesheet that defines # the CSS vars under the CSS selector `[data-djc-css-a1b2c3]`. We define the stylesheet # with variables separately from `Component.css`, because different instances may return different # data from `get_css_data()`, which will live in different stylesheets. def cache_component_css_vars(comp_cls: Type["Component"], css_vars: Mapping) -> Optional[str]: if not is_nonempty_str(comp_cls.css): return None # The hash for the file that holds the CSS variables is derived from the variables themselves. json_data = json.dumps(css_vars) input_hash = md5(json_data.encode()).hexdigest()[0:6] # Generate and cache a CSS stylesheet that contains the CSS variables. if not _is_script_in_cache(comp_cls, "css", input_hash): _cache_script( comp_cls=comp_cls, script="", # TODO - enable JS and CSS vars script_type="css", input_hash=input_hash, ) return input_hash def wrap_component_css(comp_cls: Type["Component"], content: str) -> str: if "' end tag. " "This is not allowed, as it would break the HTML." ) return f"" ######################################################### # 2. Modify the HTML to use the same IDs defined in previous # step for the inlined CSS and JS scripts, so the scripts # can be applied to the correct HTML elements. And embed # component + JS/CSS relationships as HTML comments. ######################################################### def set_component_attrs_for_js_and_css( html_content: Union[str, SafeString], component_id: Optional[str], css_input_hash: Optional[str], css_scope_id: Optional[str], root_attributes: Optional[List[str]] = None, ) -> Tuple[Union[str, SafeString], Dict[str, List[str]]]: # These are the attributes that we want to set on the root element. all_root_attributes = [*root_attributes] if root_attributes else [] # Component ID is used for executing JS script, e.g. `data-djc-id-ca1b2c3` # # NOTE: We use `data-djc-css-a1b2c3` and `data-djc-id-ca1b2c3` instead of # `data-djc-css="a1b2c3"` and `data-djc-id="a1b2c3"`, to allow # multiple values to be associated with the same element, which may happen if # one component renders another. if component_id: all_root_attributes.append(f"data-djc-id-{component_id}") # Attribute by which we bind the CSS variables to the component's CSS, # e.g. `data-djc-css-a1b2c3` if css_input_hash: all_root_attributes.append(f"data-djc-css-{css_input_hash}") # These attributes are set on all tags all_attributes = [] # We apply the CSS scoping attribute to both root and non-root tags. # # This is the HTML part of Vue-like CSS scoping. # That is, for each HTML element that the component renders, we add a `data-djc-scope-a1b2c3` attribute. # And we stop when we come across a nested components. if css_scope_id: all_attributes.append(f"data-djc-scope-{css_scope_id}") is_safestring = isinstance(html_content, SafeString) updated_html, child_components = set_html_attributes( html_content, root_attributes=all_root_attributes, all_attributes=all_attributes, # Setting this means that set_html_attributes will check for HTML elemetnts with this # attribute, and return a dictionary of {attribute_value: [attributes_set_on_this_tag]}. # # So if HTML contains tag , # and we set on that tag `data-djc-id-123`, then we will get # { # "123": ["data-djc-id-123"], # } # # This is a minor optimization. Without this, when we're rendering components in # component_post_render(), we'd have to parse each `` # to find the HTML attribute that were set on it. watch_on_attribute="djc-render-id", ) updated_html = mark_safe(updated_html) if is_safestring else updated_html return updated_html, child_components # NOTE: To better understand the next section, consider this: # # We define and cache the component's JS and CSS at the same time as # when we render the HTML. However, the resulting HTML MAY OR MAY NOT # be used in another component. # # IF the component's HTML IS used in another component, and the other # component want to render the JS or CSS dependencies (e.g. inside ), # then it's only at that point when we want to access the data about # which JS and CSS scripts is the component's HTML associated with. # # This happens AFTER the rendering context, so there's no Context to rely on. # # Hence, we store the info about associated JS and CSS right in the HTML itself. # As an HTML comment ``. Thus, the inner component can be used as many times # and in different components, and they will all know to fetch also JS and CSS of the # inner components. def insert_component_dependencies_comment( content: str, # NOTE: We pass around the component CLASS, so the dependencies logic is not # dependent on ComponentRegistries component_cls: Type["Component"], component_id: str, js_input_hash: Optional[str], css_input_hash: Optional[str], ) -> SafeString: """ Given some textual content, prepend it with a short string that will be used by the `render_dependencies()` function to collect all declared JS / CSS scripts. """ data = f"{component_cls.class_id},{component_id},{js_input_hash or ''},{css_input_hash or ''}" # NOTE: It's important that we put the comment BEFORE the content, so we can # use the order of comments to evaluate components' instance JS code in the correct order. output = mark_safe(COMPONENT_DEPS_COMMENT.format(data=data) + content) return output ######################################################### # 3. Given a FINAL HTML composed of MANY components, # process all the HTML dependency comments (created in # previous step), obtaining ALL JS and CSS scripts # required by this HTML document. And post-process them, # so the scripts are either inlined into the HTML, or # fetched when the HTML is loaded in the browser. ######################################################### TContent = TypeVar("TContent", bound=Union[bytes, str]) CSS_PLACEHOLDER_NAME = "CSS_PLACEHOLDER" CSS_PLACEHOLDER_NAME_B = CSS_PLACEHOLDER_NAME.encode() JS_PLACEHOLDER_NAME = "JS_PLACEHOLDER" JS_PLACEHOLDER_NAME_B = JS_PLACEHOLDER_NAME.encode() CSS_DEPENDENCY_PLACEHOLDER = f'' JS_DEPENDENCY_PLACEHOLDER = f'' COMPONENT_DEPS_COMMENT = "" # E.g. `` COMPONENT_COMMENT_REGEX = re.compile(rb"") # E.g. `table,123,a92ef298,bd002c3` # - comp_cls_id - Cache key of the component class that was rendered # - id - Component render ID # - js - Cache key for the JS data from `get_js_data()` # - css - Cache key for the CSS data from `get_css_data()` SCRIPT_NAME_REGEX = re.compile( rb"^(?P[\w\-\./]+?),(?P[\w]+?),(?P[0-9a-f]*?),(?P[0-9a-f]*?)$" ) # E.g. `data-djc-id-ca1b2c3` MAYBE_COMP_ID = r'(?: data-djc-id-\w{{{COMP_ID_LENGTH}}}="")?'.format(COMP_ID_LENGTH=COMP_ID_LENGTH) # E.g. `data-djc-css-99914b` MAYBE_COMP_CSS_ID = r'(?: data-djc-css-\w{6}="")?' PLACEHOLDER_REGEX = re.compile( r"{css_placeholder}|{js_placeholder}".format( css_placeholder=f'', js_placeholder=f'', ).encode() ) def render_dependencies(content: TContent, strategy: DependenciesStrategy = "document") -> TContent: """ Given a string that contains parts that were rendered by components, this function inserts all used JS and CSS. By default, the string is parsed as an HTML and: - CSS is inserted at the end of `` (if present) - JS is inserted at the end of `` (if present) If you used `{% component_js_dependencies %}` or `{% component_css_dependencies %}`, then the JS and CSS will be inserted only at these locations. Example: ```python def my_view(request): template = Template(''' {% load components %}

{{ table_name }}

{% component "table" name=table_name / %} ''') html = template.render( Context({ "table_name": request.GET["name"], }) ) # This inserts components' JS and CSS processed_html = render_dependencies(html) return HttpResponse(processed_html) ``` """ if strategy not in DEPS_STRATEGIES: raise ValueError(f"Invalid strategy '{strategy}'") elif strategy == "ignore": return content is_safestring = isinstance(content, SafeString) if isinstance(content, str): content_ = content.encode() else: content_ = cast(bytes, content) content_, js_dependencies, css_dependencies = _process_dep_declarations(content_, strategy) # Replace the placeholders with the actual content # If strategy in (`document`, 'simple'), we insert the JS and CSS directly into the HTML, # where the placeholders were. # If strategy == `fragment`, we let the client-side manager load the JS and CSS, # and remove the placeholders. did_find_js_placeholder = False did_find_css_placeholder = False css_replacement = css_dependencies if strategy in ("document", "simple") else b"" js_replacement = js_dependencies if strategy in ("document", "simple") else b"" def on_replace_match(match: "re.Match[bytes]") -> bytes: nonlocal did_find_css_placeholder nonlocal did_find_js_placeholder if CSS_PLACEHOLDER_NAME_B in match[0]: replacement = css_replacement did_find_css_placeholder = True elif JS_PLACEHOLDER_NAME_B in match[0]: replacement = js_replacement did_find_js_placeholder = True else: raise RuntimeError( "Unexpected error: Regex for component dependencies processing" f" matched unknown string '{match[0].decode()}'" ) return replacement content_ = PLACEHOLDER_REGEX.sub(on_replace_match, content_) # By default ("document") and for "simple" strategy, if user didn't specify any `{% component_dependencies %}`, # then try to insert the JS scripts at the end of and CSS sheets at the end # of . if strategy in ("document", "simple") and (not did_find_js_placeholder or not did_find_css_placeholder): maybe_transformed = _insert_js_css_to_default_locations( content_.decode(), css_content=None if did_find_css_placeholder else css_dependencies.decode(), js_content=None if did_find_js_placeholder else js_dependencies.decode(), ) if maybe_transformed is not None: content_ = maybe_transformed.encode() # In case of a fragment, we only append the JS (actually JSON) to trigger the call of dependency-manager elif strategy == "fragment": content_ += js_dependencies # For prepend / append, we insert the JS and CSS before / after the content elif strategy == "prepend": content_ = js_dependencies + css_dependencies + content_ elif strategy == "append": content_ = content_ + js_dependencies + css_dependencies # Return the same type as we were given output = content_.decode() if isinstance(content, str) else content_ output = mark_safe(output) if is_safestring else output return cast(TContent, output) # Renamed so we can access use this function where there's kwarg of the same name _render_dependencies = render_dependencies # Overview of this function: # 1. We extract all HTML comments like ``. # 2. We look up the corresponding component classes # 3. For each component class we get the component's inlined JS and CSS, # and the JS and CSS from `Media.js/css` # 4. We add our client-side JS logic into the mix (`django_components/django_components.min.js`) # - For fragments, we would skip this step. # 5. For all the above JS and CSS, we figure out which JS / CSS needs to be inserted directly # into the HTML, and which can be loaded with the client-side manager. # - Components' inlined JS is inserted directly into the HTML as `` tags in the content exec_script_data = { "loadedCssUrls": map_to_base64(loaded_css_urls), "loadedJsUrls": map_to_base64(loaded_js_urls), "toLoadCssTags": map_to_base64(to_load_css_tags), "toLoadJsTags": map_to_base64(to_load_js_tags), } # NOTE: This data is embedded into the HTML as JSON. It is the responsibility of # the client-side code to detect that this script was inserted, and to load the # corresponding assets # See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#embedding_data_in_html exec_script = json.dumps(exec_script_data) exec_script = f'' return exec_script head_or_body_end_tag_re = re.compile(r"<\/(?:head|body)\s*>", re.DOTALL) def _insert_js_css_to_default_locations( html_content: str, js_content: Optional[str], css_content: Optional[str], ) -> Optional[str]: """ This function tries to insert the JS and CSS content into the default locations. JS is inserted at the end of ``, and CSS is inserted at the end of ``. We find these tags by looking for the first `` and last `` tags. """ if css_content is None and js_content is None: return None did_modify_html = False first_end_head_tag_index = None last_end_body_tag_index = None # First check the content for the first `` and last `` tags for match in head_or_body_end_tag_re.finditer(html_content): tag_name = match[0][2:6] # We target the first ``, thus, after we set it, we skip the rest if tag_name == "head": if css_content is not None and first_end_head_tag_index is None: first_end_head_tag_index = match.start() # But for ``, we want the last occurrence, so we insert the content only # after the loop. elif tag_name == "body": if js_content is not None: last_end_body_tag_index = match.start() else: raise ValueError(f"Unexpected tag name '{tag_name}'") # Then do two string insertions. First the CSS, because we assume that is before . index_offset = 0 updated_html = html_content if css_content is not None and first_end_head_tag_index is not None: updated_html = updated_html[:first_end_head_tag_index] + css_content + updated_html[first_end_head_tag_index:] index_offset = len(css_content) did_modify_html = True if js_content is not None and last_end_body_tag_index is not None: js_index = last_end_body_tag_index + index_offset updated_html = updated_html[:js_index] + js_content + updated_html[js_index:] did_modify_html = True if did_modify_html: return updated_html else: return None # No changes made ######################################################### # 4. Endpoints for fetching the JS / CSS scripts from within # the browser, as defined from previous steps. ######################################################### CACHE_ENDPOINT_NAME = "components_cached_script" _CONTENT_TYPES = {"js": "text/javascript", "css": "text/css"} def _get_content_types(script_type: ScriptType) -> str: if script_type not in _CONTENT_TYPES: raise ValueError(f"Unknown script_type '{script_type}'") return _CONTENT_TYPES[script_type] def cached_script_view( req: HttpRequest, comp_cls_id: str, script_type: ScriptType, input_hash: Optional[str] = None, ) -> HttpResponse: from django_components.component import get_component_by_class_id if req.method != "GET": return HttpResponseNotAllowed(["GET"]) try: comp_cls = get_component_by_class_id(comp_cls_id) except KeyError: return HttpResponseNotFound() script = get_script_content(script_type, comp_cls, input_hash) if script is None: return HttpResponseNotFound() content_type = _get_content_types(script_type) return HttpResponse(content=script, content_type=content_type) urlpatterns = [ # E.g. `/components/cache/MyTable_a1b2c3.js` or `/components/cache/MyTable_a1b2c3.0ab2c3.js` path("cache/..", cached_script_view, name=CACHE_ENDPOINT_NAME), path("cache/.", cached_script_view, name=CACHE_ENDPOINT_NAME), ] ######################################################### # 5. Template tags ######################################################### def _component_dependencies(type: Literal["js", "css"]) -> SafeString: """Marks location where CSS link and JS script tags should be rendered.""" if type == "css": placeholder = CSS_DEPENDENCY_PLACEHOLDER elif type == "js": placeholder = JS_DEPENDENCY_PLACEHOLDER else: raise TemplateSyntaxError( f"Unknown dependency type in {{% component_dependencies %}}. Must be one of 'css' or 'js', got {type}" ) return mark_safe(placeholder) class ComponentCssDependenciesNode(BaseNode): """ Marks location where CSS link tags should be rendered after the whole HTML has been generated. Generally, this should be inserted into the `` tag of the HTML. If the generated HTML does NOT contain any `{% component_css_dependencies %}` tags, CSS links are by default inserted into the `` tag of the HTML. (See [Default JS / CSS locations](../../concepts/advanced/rendering_js_css/#default-js-css-locations)) Note that there should be only one `{% component_css_dependencies %}` for the whole HTML document. If you insert this tag multiple times, ALL CSS links will be duplicately inserted into ALL these places. """ tag = "component_css_dependencies" end_tag = None # inline-only allowed_flags = [] def render(self, context: Context) -> str: return _component_dependencies("css") class ComponentJsDependenciesNode(BaseNode): """ Marks location where JS link tags should be rendered after the whole HTML has been generated. Generally, this should be inserted at the end of the `` tag of the HTML. If the generated HTML does NOT contain any `{% component_js_dependencies %}` tags, JS scripts are by default inserted at the end of the `` tag of the HTML. (See [Default JS / CSS locations](../../concepts/advanced/rendering_js_css/#default-js-css-locations)) Note that there should be only one `{% component_js_dependencies %}` for the whole HTML document. If you insert this tag multiple times, ALL JS scripts will be duplicately inserted into ALL these places. """ tag = "component_js_dependencies" end_tag = None # inline-only allowed_flags = [] def render(self, context: Context) -> str: return _component_dependencies("js")