feat: Add support for HTML fragments (#845)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2024-12-19 10:03:35 +01:00 committed by GitHub
parent 6681fc0085
commit 4dab940db8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1225 additions and 246 deletions

View file

@ -1,12 +1,12 @@
"""All code related to management of component dependencies (JS and CSS scripts)"""
import base64
import json
import re
import sys
from abc import ABC, abstractmethod
from functools import lru_cache
from hashlib import md5
from textwrap import dedent
from typing import (
TYPE_CHECKING,
Callable,
@ -34,9 +34,8 @@ from django.urls import path, reverse
from django.utils.decorators import sync_and_async_middleware
from django.utils.safestring import SafeString, mark_safe
import django_components.types as types
from django_components.util.html import SoupNode
from django_components.util.misc import _escape_js, get_import_path
from django_components.util.misc import get_import_path
if TYPE_CHECKING:
from django_components.component import Component
@ -325,6 +324,9 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
return HttpResponse(processed_html)
```
"""
if type not in ("document", "fragment"):
raise ValueError(f"Invalid type '{type}'")
is_safestring = isinstance(content, SafeString)
if isinstance(content, str):
@ -335,18 +337,24 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
content_, js_dependencies, css_dependencies = _process_dep_declarations(content_, type)
# Replace the placeholders with the actual content
# If type == `document`, we insert the JS and CSS directly into the HTML,
# where the placeholders were.
# If type == `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 type == "document" else b""
js_replacement = js_dependencies if type == "document" else b""
def on_replace_match(match: "re.Match[bytes]") -> bytes:
nonlocal did_find_css_placeholder
nonlocal did_find_js_placeholder
if match[0] == CSS_PLACEHOLDER_BYTES:
replacement = css_dependencies
replacement = css_replacement
did_find_css_placeholder = True
elif match[0] == JS_PLACEHOLDER_BYTES:
replacement = js_dependencies
replacement = js_replacement
did_find_js_placeholder = True
else:
raise RuntimeError(
@ -370,6 +378,10 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
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
if type == "fragment":
content_ += js_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
@ -505,7 +517,8 @@ def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes,
# Core scripts without which the rest wouldn't work
core_script_tags = Media(
js=[static("django_components/django_components.min.js")],
# NOTE: When rendering a document, the initial JS is inserted directly into the HTML
js=[static("django_components/django_components.min.js")] if type == "document" else [],
).render_js()
final_script_tags = "".join(
@ -514,7 +527,7 @@ def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes,
*[tag for tag in core_script_tags],
# Make calls to the JS dependency manager
# Loads JS from `Media.js` and `Component.js` if fragment
exec_script,
*([exec_script] if exec_script else []),
# JS from `Media.js`
# NOTE: When rendering a document, the initial JS is inserted directly into the HTML
# so the scripts are executed at proper order. In the dependency manager, we only mark those
@ -620,7 +633,7 @@ def _prepare_tags_and_urls(
to_load_js_urls.append(get_script_url("js", comp_cls))
if _is_nonempty_str(comp_cls.css):
loaded_css_urls.append(get_script_url("css", comp_cls))
to_load_css_urls.append(get_script_url("css", comp_cls))
return (
to_load_js_urls,
@ -650,9 +663,20 @@ def _get_script_tag(
script = get_script_content(script_type, comp_cls)
if script_type == "js":
return f"<script>{_escape_js(script)}</script>"
if "</script" in script:
raise RuntimeError(
f"Content of `Component.js` for component '{comp_cls.__name__}' contains '</script>' end tag. "
"This is not allowed, as it would break the HTML."
)
return f"<script>{script}</script>"
elif script_type == "css":
if "</style" in script:
raise RuntimeError(
f"Content of `Component.css` for component '{comp_cls.__name__}' contains '</style>' end tag. "
"This is not allowed, as it would break the HTML."
)
return f"<style>{script}</style>"
return script
@ -678,51 +702,33 @@ def _gen_exec_script(
to_load_css_tags: List[str],
loaded_js_urls: List[str],
loaded_css_urls: List[str],
) -> str:
# Generate JS expression like so:
# ```js
# Promise.all([
# Components.manager.loadJs('<script src="/abc/def1">...</script>'),
# Components.manager.loadJs('<script src="/abc/def2">...</script>'),
# Components.manager.loadCss('<link href="/abc/def3">'),
# ]);
# ```
) -> Optional[str]:
if not to_load_js_tags and not to_load_css_tags and not loaded_css_urls and not loaded_js_urls:
return None
def map_to_base64(lst: List[str]) -> List[str]:
return [base64.b64encode(tag.encode()).decode() for tag in lst]
# Generate JSON that will tell the JS dependency manager which JS and CSS to load
#
# or
#
# ```js
# Components.manager.markScriptLoaded("css", "/abc/def1.css"),
# Components.manager.markScriptLoaded("css", "/abc/def2.css"),
# Components.manager.markScriptLoaded("js", "/abc/def3.js"),
# ```
#
# NOTE: It would be better to pass only the URL itself for `loadJs/loadCss`, instead of a whole tag.
# NOTE: It would be simpler to pass only the URL itself for `loadJs/loadCss`, instead of a whole tag.
# But because we allow users to specify the Media class, and thus users can
# configure how the `<link>` or `<script>` tags are rendered, we need pass the whole tag.
escaped_to_load_js_tags = [_escape_js(tag, eval=False) for tag in to_load_js_tags]
escaped_to_load_css_tags = [_escape_js(tag, eval=False) for tag in to_load_css_tags]
#
# NOTE 2: Convert to Base64 to avoid any issues with `</script>` 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),
}
# Make JS array whose items are interpreted as JS statements (e.g. functions)
def js_arr(lst: List) -> str:
return "[" + ", ".join(lst) + "]"
# NOTE: Wrap the body in self-executing function to avoid polluting the global scope.
exec_script: types.js = f"""
(() => {{
Components.manager._loadComponentScripts({{
loadedCssUrls: {json.dumps(loaded_css_urls)},
loadedJsUrls: {json.dumps(loaded_js_urls)},
toLoadCssTags: {js_arr(escaped_to_load_css_tags)},
toLoadJsTags: {js_arr(escaped_to_load_js_tags)},
}});
document.currentScript.remove();
}})();
"""
# NOTE: The exec script MUST be executed SYNC, so we MUST NOT put `type="module"`,
# `async`, nor `defer` on it.
# See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#notes
exec_script = f"<script>{_escape_js(dedent(exec_script))}</script>"
# 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'<script type="application/json" data-djc>{exec_script}</script>'
return exec_script
@ -807,8 +813,8 @@ def cached_script_view(
urlpatterns = [
# E.g. `/components/cache/table.js/`
path("cache/<str:comp_cls_hash>.<str:script_type>/", cached_script_view, name=CACHE_ENDPOINT_NAME),
# E.g. `/components/cache/table.js`
path("cache/<str:comp_cls_hash>.<str:script_type>", cached_script_view, name=CACHE_ENDPOINT_NAME),
]

View file

@ -1 +1 @@
(()=>{var x=Array.isArray,l=n=>typeof n=="function",w=n=>n!==null&&typeof n=="object",E=n=>(w(n)||l(n))&&l(n.then)&&l(n.catch);function j(n,a){try{return a?n.apply(null,a):n()}catch(r){S(r)}}function g(n,a){if(l(n)){let r=j(n,a);return r&&E(r)&&r.catch(i=>{S(i)}),[r]}if(x(n)){let r=[];for(let i=0;i<n.length;i++)r.push(g(n[i],a));return r}else console.warn(`[Components] Invalid value type passed to callWithAsyncErrorHandling(): ${typeof n}`)}function S(n){console.error(n)}var u=()=>{let n=new Set,a=new Set,r={},i={},b=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("script");if(!e)throw Error("[Components] Failed to extract <script> tag. Make sure that the string contains <script><\/script> and is a valid HTML");return e},M=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("link");if(!e)throw Error("[Components] Failed to extract <link> tag. Make sure that the string contains <link></link> and is a valid HTML");return e},y=t=>{let e=document.createElement(t.tagName);e.innerHTML=t.innerHTML;for(let o of t.attributes)e.setAttributeNode(o.cloneNode());return e},h=t=>{let e=b(t),o=e.getAttribute("src");if(!o||T("js",o))return;c("js",o);let s=y(e),p=e.getAttribute("async")!=null||e.getAttribute("defer")!=null||e.getAttribute("type")==="module";s.async=p;let m=new Promise((d,f)=>{s.onload=()=>{d()},globalThis.document.body.append(s)});return{el:s,promise:m}},C=t=>{let e=M(t),o=e.getAttribute("href");if(!o||T("css",o))return;let s=y(e);return globalThis.document.head.append(s),c("css",o),{el:s,promise:Promise.resolve()}},c=(t,e)=>{if(t!=="js"&&t!=="css")throw Error(`[Components] markScriptLoaded received invalid script type '${t}'. Must be one of 'js', 'css'`);(t==="js"?n:a).add(e)},T=(t,e)=>{if(t!=="js"&&t!=="css")throw Error(`[Components] isScriptLoaded received invalid script type '${t}'. Must be one of 'js', 'css'`);return(t==="js"?n:a).has(e)};return{callComponent:(t,e,o)=>{let s=r[t];if(!s)throw Error(`[Components] '${t}': No component registered for that name`);let p=Array.from(document.querySelectorAll(`[data-comp-id-${e}]`));if(!p.length)throw Error(`[Components] '${t}': No elements with component ID '${e}' found`);let m=`${t}:${o}`,d=i[m];if(!d)throw Error(`[Components] '${t}': Cannot find input for hash '${o}'`);let f=d(),F={name:t,id:e,els:p},[L]=g(s,[f,F]);return L},registerComponent:(t,e)=>{r[t]=e},registerComponentData:(t,e,o)=>{let s=`${t}:${e}`;i[s]=o},loadJs:h,loadCss:C,markScriptLoaded:c,_loadComponentScripts:async t=>{t.loadedCssUrls.forEach(o=>c("css",o)),t.loadedJsUrls.forEach(o=>c("js",o)),Promise.all(t.toLoadCssTags.map(o=>C(o))).catch(console.error);let e=Promise.all(t.toLoadJsTags.map(o=>h(o))).catch(console.error)}}};var k={manager:u(),createComponentsManager:u,unescapeJs:r=>new DOMParser().parseFromString(r,"text/html").documentElement.textContent};globalThis.Components=k;})();
(()=>{var x=o=>new DOMParser().parseFromString(o,"text/html").documentElement.textContent,E=Array.isArray,m=o=>typeof o=="function",H=o=>o!==null&&typeof o=="object",S=o=>(H(o)||m(o))&&m(o.then)&&m(o.catch);function N(o,i){try{return i?o.apply(null,i):o()}catch(s){L(s)}}function g(o,i){if(m(o)){let s=N(o,i);return s&&S(s)&&s.catch(c=>{L(c)}),[s]}if(E(o)){let s=[];for(let c=0;c<o.length;c++)s.push(g(o[c],i));return s}else console.warn(`[Components] Invalid value type passed to callWithAsyncErrorHandling(): ${typeof o}`)}function L(o){console.error(o)}var M=o=>{let i=new MutationObserver(s=>{for(let c of s)c.type==="childList"&&c.addedNodes.forEach(p=>{p.nodeName==="SCRIPT"&&p.hasAttribute("data-djc")&&o(p)})});return i.observe(document,{childList:!0,subtree:!0}),i};var y=()=>{let o=new Set,i=new Set,s={},c={},p=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("script");if(!e)throw Error("[Components] Failed to extract <script> tag. Make sure that the string contains <script><\/script> and is a valid HTML");return e},F=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("link");if(!e)throw Error("[Components] Failed to extract <link> tag. Make sure that the string contains <link></link> and is a valid HTML");return e},T=t=>{let e=document.createElement(t.tagName);e.innerHTML=t.innerHTML;for(let r of t.attributes)e.setAttributeNode(r.cloneNode());return e},f=t=>{let e=p(t),r=e.getAttribute("src");if(!r||C("js",r))return;d("js",r);let a=T(e),l=e.getAttribute("async")!=null||e.getAttribute("defer")!=null||e.getAttribute("type")==="module";a.async=l;let u=new Promise((n,b)=>{a.onload=()=>{n()},globalThis.document.body.append(a)});return{el:a,promise:u}},h=t=>{let e=F(t),r=e.getAttribute("href");if(!r||C("css",r))return;let a=T(e);return globalThis.document.head.append(a),d("css",r),{el:a,promise:Promise.resolve()}},d=(t,e)=>{if(t!=="js"&&t!=="css")throw Error(`[Components] markScriptLoaded received invalid script type '${t}'. Must be one of 'js', 'css'`);(t==="js"?o:i).add(e)},C=(t,e)=>{if(t!=="js"&&t!=="css")throw Error(`[Components] isScriptLoaded received invalid script type '${t}'. Must be one of 'js', 'css'`);return(t==="js"?o:i).has(e)},w=(t,e)=>{s[t]=e},j=(t,e,r)=>{let a=`${t}:${e}`;c[a]=r},A=(t,e,r)=>{let a=s[t];if(!a)throw Error(`[Components] '${t}': No component registered for that name`);let l=Array.from(document.querySelectorAll(`[data-comp-id-${e}]`));if(!l.length)throw Error(`[Components] '${t}': No elements with component ID '${e}' found`);let u=`${t}:${r}`,n=c[u];if(!n)throw Error(`[Components] '${t}': Cannot find input for hash '${r}'`);let b=n(),v={name:t,id:e,els:l},[P]=g(a,[b,v]);return P},k=async t=>{let e=t.loadedCssUrls.map(n=>atob(n)),r=t.loadedJsUrls.map(n=>atob(n)),a=t.toLoadCssTags.map(n=>atob(n)),l=t.toLoadJsTags.map(n=>atob(n));e.forEach(n=>d("css",n)),r.forEach(n=>d("js",n)),Promise.all(a.map(n=>h(n))).catch(console.error);let u=Promise.all(l.map(n=>f(n))).catch(console.error)};return M(t=>{let e=JSON.parse(t.text);k(e)}),{callComponent:A,registerComponent:w,registerComponentData:j,loadJs:f,loadCss:h,markScriptLoaded:d}};var $={manager:y(),createComponentsManager:y,unescapeJs:x};globalThis.Components=$;})();

View file

@ -1,8 +1,6 @@
import re
from typing import Any, Callable, List, Optional, Type, TypeVar
from django.template.defaultfilters import escape
from django_components.util.nanoid import generate
T = TypeVar("T")
@ -52,22 +50,6 @@ def get_import_path(cls_or_fn: Type[Any]) -> str:
return module + "." + cls_or_fn.__qualname__
# See https://stackoverflow.com/a/58800331/9788634
# str.replace(/\\|`|\$/g, '\\$&');
JS_STRING_LITERAL_SPECIAL_CHARS_REGEX = re.compile(r"\\|`|\$")
# See https://stackoverflow.com/a/34064434/9788634
def escape_js_string_literal(js: str) -> str:
escaped_js = escape(js)
def on_replace_match(match: "re.Match[str]") -> str:
return f"\\{match[0]}"
escaped_js = JS_STRING_LITERAL_SPECIAL_CHARS_REGEX.sub(on_replace_match, escaped_js)
return escaped_js
def default(val: Optional[T], default: T) -> T:
return val if val is not None else default
@ -77,10 +59,3 @@ def get_last_index(lst: List, key: Callable[[Any], bool]) -> Optional[int]:
if key(item):
return len(lst) - 1 - index
return None
def _escape_js(js: str, eval: bool = True) -> str:
escaped_js = escape_js_string_literal(js)
# `unescapeJs` is the function we call in the browser to parse the escaped JS
escaped_js = f"Components.unescapeJs(`{escaped_js}`)"
return f"eval({escaped_js})" if eval else escaped_js

View file

@ -1,22 +1,14 @@
/** This file defines the API of the JS code. */
import { createComponentsManager } from './manager';
import { unescapeJs } from './utils';
export type * from './manager';
export const Components = (() => {
const manager = createComponentsManager();
/** Unescape JS that was escaped in Django side with `escape_js` */
const unescapeJs = (escapedJs: string) => {
return new DOMParser().parseFromString(escapedJs, 'text/html').documentElement.textContent;
};
return {
manager,
createComponentsManager,
unescapeJs,
};
})();
export const Components = {
manager: createComponentsManager(),
createComponentsManager,
unescapeJs,
};
// In browser, this is accessed as `Components.manager`, etc
globalThis.Components = Components;

View file

@ -1,5 +1,7 @@
/** The actual code of the JS dependency manager */
import { callWithAsyncErrorHandling } from './errorHandling';
import { observeScriptTag } from './mutationObserver';
import { unescapeJs } from './utils';
type MaybePromise<T> = Promise<T> | T;
@ -233,24 +235,35 @@ export const createComponentsManager = () => {
toLoadCssTags: string[];
toLoadJsTags: string[];
}) => {
const loadedCssUrls = inputs.loadedCssUrls.map((s) => atob(s));
const loadedJsUrls = inputs.loadedJsUrls.map((s) => atob(s));
const toLoadCssTags = inputs.toLoadCssTags.map((s) => atob(s));
const toLoadJsTags = inputs.toLoadJsTags.map((s) => atob(s));
// Mark as loaded the CSS that WAS inlined into the HTML.
inputs.loadedCssUrls.forEach((s) => markScriptLoaded("css", s));
inputs.loadedJsUrls.forEach((s) => markScriptLoaded("js", s));
loadedCssUrls.forEach((s) => markScriptLoaded("css", s));
loadedJsUrls.forEach((s) => markScriptLoaded("js", s));
// Load CSS that was not inlined into the HTML
// NOTE: We don't need to wait for CSS to load
Promise
.all(inputs.toLoadCssTags.map((s) => loadCss(s)))
.all(toLoadCssTags.map((s) => loadCss(s)))
.catch(console.error);
// Load JS that was not inlined into the HTML
const jsScriptsPromise = Promise
// NOTE: Interestingly enough, when we insert scripts into the DOM programmatically,
// the order of execution is the same as the order of insertion.
.all(inputs.toLoadJsTags.map((s) => loadJs(s)))
.all(toLoadJsTags.map((s) => loadJs(s)))
.catch(console.error);
};
// Initialise the MutationObserver that watches for `<script>` tags with `data-djc` attribute
observeScriptTag((script) => {
const data = JSON.parse(script.text);
_loadComponentScripts(data);
});
return {
callComponent,
registerComponent,
@ -258,6 +271,5 @@ export const createComponentsManager = () => {
loadJs,
loadCss,
markScriptLoaded,
_loadComponentScripts,
};
};

View file

@ -0,0 +1,27 @@
/** Set up MutationObserver that watches for `<script>` tags with `data-djc` attribute */
export const observeScriptTag = (onScriptTag: (node: HTMLScriptElement) => void) => {
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === "childList") {
// Check added nodes
mutation.addedNodes.forEach((node) => {
if (
node.nodeName === "SCRIPT" &&
(node as HTMLElement).hasAttribute("data-djc")
) {
onScriptTag(node as HTMLScriptElement);
}
});
2;
}
}
});
// Observe the entire document
observer.observe(document, {
childList: true,
subtree: true, // To detect nodes added anywhere in the DOM
});
return observer;
};

View file

@ -1,10 +1,19 @@
// Helper functions taken from @vue/shared
/** Unescape JS that was escaped in Django side with `escape_js` */
export const unescapeJs = (escapedJs: string) => {
const doc = new DOMParser().parseFromString(escapedJs, "text/html")
return doc.documentElement.textContent as string;
};
// ////////////////////////////////////////////////////////
// Helper functions below were taken from @vue/shared
// See https://github.com/vuejs/core/blob/91112520427ff55941a1c759d7d60a0811ff4a61/packages/shared/src/general.ts#L105
// ////////////////////////////////////////////////////////
export const isArray = Array.isArray;
export const isFunction = (val: unknown): val is Function => typeof val === 'function';
export const isFunction = (val: unknown): val is Function =>
typeof val === "function";
export const isObject = (val: unknown): val is Record<any, any> => {
return val !== null && typeof val === 'object';
return val !== null && typeof val === "object";
};
export const isPromise = <T = any>(val: unknown): val is Promise<T> => {
return (