Source code for duck.html.components.script

"""
Script HTML Component.

This module defines a reusable `Script` component for embedding JavaScript code within an HTML document.
"""

from duck.html.components import InnerComponent
from duck.csp import csp_nonce, csp_nonce_flag


# Tokens after which a "/" is grammatically a regex literal, not division.
# Deliberately excludes ")" and "]" -- after those a "/" is division
# (e.g. "(a + b) / 2", "arr[0] / 2").
_JS_REGEX_PRECEDING_PUNCTUATION = set("([{,;:=!&|?+-*/%^~<>")
_JS_REGEX_PRECEDING_KEYWORDS = {
    "return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
    "throw", "case", "do", "else", "yield", "await",
}

# Characters a space next to which is never needed: none of them combine
# with an adjacent character to form a different token (unlike, say, two
# "+" characters merging into "++", or two identifier characters merging
# into one longer identifier).
_JS_SAFE_SPACE_BOUNDARY = set("(){}[],;:")


[docs] def minify_js(js: str) -> str: """ Conservatively minifies JavaScript: strips comments and collapses incidental whitespace (indentation, blank lines, redundant inline spaces), without ever removing a newline that follows real code. JavaScript relies on newlines for Automatic Semicolon Insertion (ASI) -- `return\\n{ a: 1 }` and `return { a: 1 }` behave differently -- so merging lines the way a CSS minifier safely can is not safe here. This function only drops blank/whitespace-only lines, leading indentation, comments, and inline spaces/tabs; every newline that separates two lines of real code is kept. A space between two tokens is dropped entirely -- not just collapsed -- whenever either token is a character that never combines with a neighbor to form a different one, such as "(", "{", ",", ";", or ":" (so "function foo() {" becomes "function foo(){"). A space is kept whenever both sides could merge into something else if it were removed -- two identifier/keyword/number characters (`return x` must not become `returnx`), or two operator characters that could combine into a different operator (`a + +b` must not become `a++b`). String and regex literal contents are left completely untouched. Template literals (including their ``${...}`` substitutions) are preserved exactly as written and are not minified internally, to avoid any risk of misinterpreting nested code as whitespace. Args: js (str): Raw JavaScript source. Returns: The minified JavaScript. """ if not js: return js return ( _minify_js_scan(js) .strip() .replace(";\n", ";") .replace("{\n", "{") .replace("}\n", "}") .replace(")\n", ")") .replace("(\n", "(") )
[docs] def _minify_js_scan(js: str) -> str: """ Single-pass scanner that strips comments and incidental whitespace while copying strings, template literals, and regex literals verbatim. """ output = [] length = len(js) last_token = "" i = 0 while i < length: ch = js[i] if ch in ("'", '"'): start = i i = _skip_js_string(js, i) output.append(js[start:i]) last_token = ch continue if ch == "`": start = i i = _skip_js_template(js, i) output.append(js[start:i]) last_token = ch continue if ch == "/" and i + 1 < length and js[i + 1] == "/": j = js.find("\n", i) i = length if j == -1 else j continue if ch == "/" and i + 1 < length and js[i + 1] == "*": j = js.find("*/", i + 2) i = length if j == -1 else j + 2 continue if ch == "/" and _js_regex_may_start(last_token): end = _skip_js_regex(js, i) if end is not None: output.append(js[i:end]) last_token = "/" i = end continue # Not actually a regex (unterminated) -- fall through and # treat it as an ordinary division/punctuation character. if ch in " \t": j = i while j < length and js[j] in " \t": j += 1 # Only keep the space if dropping it could change what the # code means -- i.e. neither side is a character that always # stands alone, and there is a real token on both sides prev_char = output[-1][-1] if output else None next_char = js[j] if j < length else None keep_space = ( prev_char is not None and next_char is not None and next_char != "\n" and prev_char not in _JS_SAFE_SPACE_BOUNDARY and next_char not in _JS_SAFE_SPACE_BOUNDARY ) if keep_space: output.append(" ") i = j continue if ch == "\n": j = i while j < length and js[j] in " \t\r\n": j += 1 if not output or output[-1] != "\n": output.append("\n") i = j continue if ch.isalnum() or ch in "_$": j = i while j < length and (js[j].isalnum() or js[j] in "_$"): j += 1 word = js[i:j] output.append(word) last_token = word i = j continue output.append(ch) last_token = ch i += 1 return "".join(output)
[docs] def _skip_js_string(js: str, i: int) -> int: """ Returns the index just past the closing quote of the string literal starting at index i. """ quote = js[i] length = len(js) j = i + 1 while j < length: if js[j] == "\\": j += 2 continue if js[j] == quote: return j + 1 if js[j] == "\n": # Unterminated string -- stop here rather than swallow the rest. return j j += 1 return j
[docs] def _skip_js_template(js: str, i: int) -> int: """ Returns the index just past the closing backtick of the template literal starting at index i, treating any ``${...}`` substitution (including nested template literals within it) as opaque. """ length = len(js) j = i + 1 while j < length: ch = js[j] if ch == "\\": j += 2 continue if ch == "`": return j + 1 if ch == "$" and j + 1 < length and js[j + 1] == "{": j = _skip_js_template_expression(js, j + 2) continue j += 1 return j
[docs] def _skip_js_template_expression(js: str, i: int) -> int: """ Returns the index just past the "}" that closes a ``${...}`` substitution, given i just after its opening "${". """ length = len(js) depth = 1 j = i while j < length and depth > 0: ch = js[j] if ch == "\\": j += 2 continue if ch in ("'", '"'): j = _skip_js_string(js, j) continue if ch == "`": j = _skip_js_template(js, j) continue if ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: return j + 1 j += 1 return j
[docs] def _skip_js_regex(js: str, i: int) -> int: """ Attempts to find the end of a regex literal starting at index i. Returns: The index just past the closing "/" and its flags, or None if this doesn't look like a valid regex literal (the caller should then treat the "/" as division instead). """ length = len(js) j = i + 1 in_class = False while j < length: ch = js[j] if ch == "\\": j += 2 continue if ch == "\n": return None if ch == "[": in_class = True elif ch == "]": in_class = False elif ch == "/" and not in_class: j += 1 while j < length and js[j].isalpha(): j += 1 return j j += 1 return None
[docs] def _js_regex_may_start(last_token: str) -> bool: """ Heuristic: is a "/" at this point grammatically a regex literal rather than division, based on the preceding token? """ if not last_token: return True if last_token[-1] in _JS_REGEX_PRECEDING_PUNCTUATION: return True return last_token in _JS_REGEX_PRECEDING_KEYWORDS
[docs] class Script(InnerComponent): """ Script HTML Component. The `Script` component allows developers to embed JavaScript code within an HTML page dynamically. It can be used to define inline scripts that interact with other components. Args: inner_html (str): The raw JavaScript to embed. minify (bool): Optional. Whether to minify the JavaScript. Defaults to True. **Features:** - Supports inline JavaScript execution. - Can be dynamically added to any component. - Provides flexibility for defining custom client-side logic. - Minifies the embedded script by default (comments, indentation, and blank lines only -- see `minify_js`), pass `minify=False` to disable. **Example Usage:** ```py script = Script( inner_html=''' function showAlert() { alert("Hello, world!"); } ''' ) component.add_child(script) ``` This will generate the following HTML output: ```html <script> function showAlert() {alert("Hello, world!");} </script> ``` **Notes:** - **Automatic Nonce Addition**: When `ENABLE_HEADERS_SECURITY_POLICY=True` and `csp_nonce_flag` is set in `CSP_TRUSTED_SOURCES`, the `nonce` property is automatically added. - **Request Resolution Required**: For the `nonce` to be set automatically, the request must be resolved. This is achieved by calling `get_request_or_raise` on the component root (or the current component if the root is `None`). """
[docs] def on_create(self) -> None: """ Initializes the component and, unless disabled, minifies the embedded script. """ super().on_create() if self.kwargs.get("minify", True) and self.inner_html: self.inner_html = minify_js(self.inner_html)
@property def properties(self) -> dict: from duck.settings import SETTINGS # Get original props props = super().properties # Set CSP configuration. if SETTINGS['ENABLE_HEADERS_SECURITY_POLICY']: current_nonce = props.get("nonce") if not current_nonce: self.set_csp_nonce() return props
[docs] def set_csp_nonce(self) -> None: """ This tries to retrieve current request nonce. """ from duck.settings import SETTINGS from duck.html.components.extensions import RequestNotFoundError try: root = self.get_raw_root() request = root.get_request_or_raise() except RequestNotFoundError: try: request = self.get_request_or_raise() except RequestNotFoundError: return # Set CSP configuration csp_directives = SETTINGS['CSP_TRUSTED_SOURCES'] if csp_directives and request: script_src = set(csp_directives.get("script-src")) if csp_nonce_flag in script_src: nonce = csp_nonce(request) # Use _get_raw_props instead to avoid recursion if this method is executed # from properties/props property method. self._get_raw_props()["nonce"] = nonce
[docs] def get_element(self) -> str: """ Returns the HTML tag for the component. """ return "script"