Repository: zauberzeug/nicegui
Pull request #3792 (2024-09-27 09:49:16)

Serve codehilite CSS for `ui.markdown` more efficiently
This PR removes the repetitive CSS code from `ui.markdown`'s props and creates a separate endpoint instead. This way the browser can cache the CSS response for multiple Markdown elements.

---

Related discussion: #3745


Patch:
diff --git a/nicegui/element.py b/nicegui/element.py
index d3c3a733..586d4e30 100644
--- a/nicegui/element.py
+++ b/nicegui/element.py
@@ -518,7 +518,7 @@ class Element(Visibility):
             additions.append(f'text={shorten(self._text)}')
         if hasattr(self, 'content') and self.content:  # pylint: disable=no-member
             additions.append(f'content={shorten(self.content)}')  # pylint: disable=no-member
-        IGNORED_PROPS = {'loopback', 'color', 'view', 'innerHTML', 'codehilite_css'}
+        IGNORED_PROPS = {'loopback', 'color', 'view', 'innerHTML', 'codehilite_css_url'}
         additions += [
             f'{key}={shorten(value)}'
             for key, value in self._props.items()
diff --git a/nicegui/elements/markdown.js b/nicegui/elements/markdown.js
index b5a28bc7..ad9f54ed 100644
--- a/nicegui/elements/markdown.js
+++ b/nicegui/elements/markdown.js
@@ -1,7 +1,10 @@
+import { loadResource } from "../../static/utils/resources.js";
+
 export default {
   template: `<div></div>`,
   async mounted() {
-    this.ensure_codehilite_css();
+    await this.$nextTick(); // NOTE: wait for window.path_prefix to be set
+    await loadResource(window.path_prefix + this.codehilite_css_url);
     if (this.use_mermaid) {
       this.mermaid = (await import("mermaid")).default;
       this.renderMermaid();
@@ -21,17 +24,9 @@ export default {
         await this.mermaid.run({ nodes: [pre.children[0]] });
       });
     },
-    ensure_codehilite_css() {
-      if (!document.querySelector(`style[data-codehilite-css]`)) {
-        const style = document.createElement("style");
-        style.setAttribute("data-codehilite-css", "");
-        style.innerHTML = this.codehilite_css;
-        document.head.appendChild(style);
-      }
-    },
   },
   props: {
-    codehilite_css: String,
+    codehilite_css_url: String,
     use_mermaid: {
       required: false,
       default: false,
diff --git a/nicegui/elements/markdown.py b/nicegui/elements/markdown.py
index 8c336654..06301912 100644
--- a/nicegui/elements/markdown.py
+++ b/nicegui/elements/markdown.py
@@ -3,11 +3,16 @@ from functools import lru_cache
 from typing import List
 
 import markdown2
+from fastapi.responses import PlainTextResponse
 from pygments.formatters import HtmlFormatter  # pylint: disable=no-name-in-module
 
+from .. import core
+from ..version import __version__
 from .mermaid import Mermaid
 from .mixins.content_element import ContentElement
 
+CODEHILITE_CSS_URL = f'/_nicegui/{__version__}/codehilite.css'
+
 
 class Markdown(ContentElement, component='markdown.js'):
 
@@ -25,14 +30,18 @@ class Markdown(ContentElement, component='markdown.js'):
         self.extras = extras[:]
         super().__init__(content=content)
         self._classes.append('nicegui-markdown')
-        self._props['codehilite_css'] = (
-            HtmlFormatter(nobackground=True).get_style_defs('.codehilite') +
-            HtmlFormatter(nobackground=True, style='github-dark').get_style_defs('.body--dark .codehilite')
-        )
         if 'mermaid' in extras:
             self._props['use_mermaid'] = True
             self.libraries.append(Mermaid.exposed_libraries[0])
 
+        self._props['codehilite_css_url'] = CODEHILITE_CSS_URL
+        if not any(r for r in core.app.routes if getattr(r, 'path', None) == CODEHILITE_CSS_URL):
+            core.app.get(CODEHILITE_CSS_URL)(lambda: PlainTextResponse(
+                HtmlFormatter(nobackground=True).get_style_defs('.codehilite') +
+                HtmlFormatter(nobackground=True, style='github-dark').get_style_defs('.body--dark .codehilite'),
+                media_type='text/css',
+            ))
+
     def _handle_content_change(self, content: str) -> None:
         html = prepare_content(content, extras=' '.join(self.extras))
         if self._props.get('innerHTML') != html:
diff --git a/nicegui/static/nicegui.js b/nicegui/static/nicegui.js
index 085309f8..c525fce4 100644
--- a/nicegui/static/nicegui.js
+++ b/nicegui/static/nicegui.js
@@ -158,7 +158,11 @@ function renderRecursively(elements, id) {
   Object.entries(props).forEach(([key, value]) => {
     if (key.startsWith(":")) {
       try {
-        props[key.substring(1)] = eval(value);
+        try {
+          props[key.substring(1)] = new Function(`return (${value})`)();
+        } catch (e) {
+          props[key.substring(1)] = eval(value);
+        }
         delete props[key];
       } catch (e) {
         console.error(`Error while converting ${key} attribute to function:`, e);
diff --git a/nicegui/static/utils/dynamic_properties.js b/nicegui/static/utils/dynamic_properties.js
index d8ab0a23..c13f9559 100644
--- a/nicegui/static/utils/dynamic_properties.js
+++ b/nicegui/static/utils/dynamic_properties.js
@@ -11,7 +11,11 @@ export function convertDynamicProperties(obj, recursive) {
   for (const [attr, value] of Object.entries(obj)) {
     if (attr.startsWith(":")) {
       try {
-        obj[attr.slice(1)] = new Function(`return (${value})`)();
+        try {
+          obj[attr.slice(1)] = new Function(`return (${value})`)();
+        } catch (e) {
+          obj[attr.slice(1)] = eval(value);
+        }
         delete obj[attr];
       } catch (e) {
         console.error(`Error while converting ${attr} attribute to function:`, e);


Test patch:
diff --git a/tests/test_endpoint_docs.py b/tests/test_endpoint_docs.py
index 2921f7ed..42d87251 100644
--- a/tests/test_endpoint_docs.py
+++ b/tests/test_endpoint_docs.py
@@ -3,7 +3,7 @@ from typing import Set
 import pytest
 import requests
 
-from nicegui import __version__
+from nicegui import __version__, ui
 from nicegui.testing import Screen
 
 
@@ -29,8 +29,11 @@ def test_endpoint_documentation_page_only(screen: Screen):
 
 def test_endpoint_documentation_internal_only(screen: Screen):
     screen.ui_run_kwargs['endpoint_documentation'] = 'internal'
+    ui.markdown('Hey!')
+
     screen.open('/')
     assert get_openapi_paths() == {
+        f'/_nicegui/{__version__}/codehilite.css',
         f'/_nicegui/{__version__}/libraries/{{key}}',
         f'/_nicegui/{__version__}/components/{{key}}',
         f'/_nicegui/{__version__}/resources/{{key}}/{{path}}',
@@ -39,9 +42,12 @@ def test_endpoint_documentation_internal_only(screen: Screen):
 
 def test_endpoint_documentation_all(screen: Screen):
     screen.ui_run_kwargs['endpoint_documentation'] = 'all'
+    ui.markdown('Hey!')
+
     screen.open('/')
     assert get_openapi_paths() == {
         '/',
+        f'/_nicegui/{__version__}/codehilite.css',
         f'/_nicegui/{__version__}/libraries/{{key}}',
         f'/_nicegui/{__version__}/components/{{key}}',
         f'/_nicegui/{__version__}/resources/{{key}}/{{path}}',