"""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