Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/frontend_architecture/components.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Before creating new components, check for existing ones in this order:

1. **Kolibri Design System** (``kolibri-design-system``) — Always prefer KDS components first. Browse the catalog at https://design-system.learningequality.org/
2. **Kolibri package** (``packages/kolibri/components/``) — Core application components such as ``AuthMessage``, ``CoreTable``, ``BottomAppBar``, and ``DownloadButton``
3. **Kolibri-Common package** (``packages/kolibri-common/components/``) — Shared components used across plugins, such as ``AccordionContainer``, ``BaseToolbar``, and ``MetadataChips``
3. **Kolibri-Common package** (``packages/kolibri-common/components/``) — Shared components used across plugins, such as ``AccordionContainer``, ``EmbeddedReadCard``, and ``MetadataChips``

Only create a new component if none of the above provide what you need.

Expand Down
145 changes: 131 additions & 14 deletions kolibri/core/content/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,38 @@
Kolibri Content hooks
---------------------

Hooks for managing the display and rendering of content.
Hooks for managing the display and viewing of content.
"""

import json
import logging
from abc import abstractmethod

from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.safestring import mark_safe
from importlib_resources import files
from le_utils.constants import file_formats
from le_utils.constants import format_presets

from kolibri.core.content.utils.paths import zip_content_static_root
from kolibri.core.utils.urls import join_url
from kolibri.core.webpack.hooks import WebpackBundleHook
from kolibri.core.webpack.hooks import WebpackInclusionMixin
from kolibri.plugins.hooks import define_hook
from kolibri.plugins.hooks import KolibriHook

logger = logging.getLogger(__name__)


@define_hook
class ContentRendererHook(WebpackBundleHook, WebpackInclusionMixin):
class ContentViewerHook(WebpackBundleHook, WebpackInclusionMixin):
"""
An inheritable hook that allows special behaviour for a frontend module that defines
a content renderer.
a content viewer.
"""

#: Set tuple of format presets that this content renderer can handle
#: Set tuple of format presets that this content viewer can handle
@property
@abstractmethod
def presets(self):
Expand Down Expand Up @@ -63,28 +70,36 @@ def html(cls):
tags.append(hook.template_html())
return mark_safe("\n".join(tags))

def template_html(self):
@property
def viewer_data(self):
"""
Generates template tags containing data to register a content renderer.
Data registering this content viewer with the frontend.

:returns: HTML of a template tags to insert into a page.
:returns: dict serialized into this viewer's template tag.
"""
# Note, while most plugins use sorted chunks to filter by text direction
# content renderers do not, as they may need to have styling for a different
# content viewers do not, as they may need to have styling for a different
# text direction than the interface due to the text direction of content
urls = [chunk["url"] for chunk in self.bundle]
return {
"urls": [chunk["url"] for chunk in self.bundle],
"presets": self.presets,
"css_selectors": self.all_css_selectors(),
}

def template_html(self):
"""
Generates template tags containing data to register a content viewer.

:returns: HTML of a template tags to insert into a page.
"""
tags = (
self.frontend_message_tag()
+ self.plugin_data_tag()
+ [
'<template data-viewer="{bundle}">{data}</template>'.format(
bundle=self.unique_id,
data=json.dumps(
{
"urls": urls,
"presets": self.presets,
"css_selectors": self.all_css_selectors(),
},
self.viewer_data,
separators=(",", ":"),
ensure_ascii=False,
cls=DjangoJSONEncoder,
Expand All @@ -95,6 +110,108 @@ def template_html(self):
return mark_safe("\n".join(tags))


# Backwards compatibility alias
ContentRendererHook = ContentViewerHook


@define_hook
class SandboxedContentViewerHook(ContentViewerHook):
"""
A content viewer that uses the Kolibri sandbox with a dynamically loaded handler.

Subclasses must define:
- bundle_id: The main viewer bundle ID (inherited from WebpackBundleHook)
- presets: Tuple of format presets this viewer handles (inherited from ContentViewerHook)
- sandbox_handler_id: The bundle ID of the sandbox handler

The sandbox handler is built separately with no Kolibri externals and loaded
dynamically into the sandbox iframe at runtime.
"""

@property
@abstractmethod
def sandbox_handler_id(self):
"""
Bundle ID of the sandbox handler.
This should match a bundle defined in buildConfig.js with sandbox_handler: true
"""
pass

@property
def sandbox_static_path(self):
"""
Returns the filesystem path to the plugin's static directory.
"""
return str(files(self._module_path).joinpath("static"))

@classmethod
def get_sandbox_static_paths(cls):
"""
Returns a list of filesystem paths to static directories
that should be mounted on the sandbox server.

Includes:
- Core content static directory (kolibri/core/content/static)
- Plugin static directories for each registered sandbox handler
"""
core_static_path = str(files("kolibri.core.content").joinpath("static"))
return [core_static_path] + [
hook.sandbox_static_path for hook in cls.registered_hooks
]

@property
def sandbox_handler_unique_id(self):
"""Full unique ID for the sandbox handler bundle."""
return "{}.{}".format(self._module_path, self.sandbox_handler_id)

def _get_sandbox_handler_stats(self):

@rtibblesbot rtibblesbot Aug 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — addressed in the current code.

suggestion: stats are re-read and re-parsed from disk on every page render. The parent caches the equivalent read (kolibri/core/webpack/hooks.py:87-90, _cached_stats_file_content gated on DEVELOPER_MODE); this one has no cache. viewer_data reaches sandbox_handler_url on every call, and {% content_viewer_assets %} (kolibri/core/templates/kolibri/base.html:47) renders viewer_data for every registered viewer on every HTML page load — three extra reads plus JSON parses per render with html5, h5p and bloompub registered. Mirroring the parent's _cached_*/DEVELOPER_MODE pattern keeps dev-mode rebuild behaviour and removes the steady-state cost.

(The unconditional WebpackError here is what I asked for last round and I still think it is right — this is a separate concern in the same method.)

"""Load stats file for the sandbox handler bundle."""
developer_mode = getattr(settings, "DEVELOPER_MODE", False)
if hasattr(self, "_cached_sandbox_handler_stats") and not developer_mode:
return self._cached_sandbox_handler_stats

stats = self.resolve_stats(self.sandbox_handler_unique_id)

self._cached_sandbox_handler_stats = stats
return stats

@property
def sandbox_handler_url(self):
"""URL to the built sandbox handler JavaScript file."""
stats = self._get_sandbox_handler_stats()
chunks = stats.get("chunks", {}).get(self.sandbox_handler_unique_id, [])

for chunk in chunks:
name = chunk.get("name", "")
if name.endswith(".js"):
relpath = "{}/{}".format(self.sandbox_handler_unique_id, name)
if getattr(settings, "DEVELOPER_MODE", False):
url = chunk.get("publicPath")
if url and not url.startswith("auto"):
return url
# The handler <script> is loaded inside the sandbox iframe, which
# is served from the alternate (zip content) origin. Serve the
# handler from that origin's static root — where alt_wsgi mounts
# the plugin static dirs — not the main-origin STATIC_URL, which
# 404s when resolved against the iframe's origin.
return join_url(zip_content_static_root(), relpath)

return None

@property
def viewer_data(self):
"""
Extends the base payload with the sandbox handler URL, when built.
"""
# `define_hook` rebuilds the class through KolibriHookMeta, so the class
# zero-argument `super()` closes over is not in the instance's MRO.
data = super(SandboxedContentViewerHook, self).viewer_data
handler_url = self.sandbox_handler_url
if handler_url:
data["sandboxHandlerUrl"] = handler_url
return data


@define_hook
class ContentNodeDisplayHook(KolibriHook):
"""
Expand Down
15 changes: 7 additions & 8 deletions kolibri/core/content/templatetags/content_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

{% load webpack_tags %}

<!-- Render on-demand async inclusion tag for content renderers -->
{% content_renderer_assets %}
<!-- Render on-demand async inclusion tag for content viewers -->
{% content_viewer_assets %}

"""

Expand All @@ -21,13 +21,12 @@


@register.simple_tag()
def content_renderer_assets():
def content_viewer_assets():
"""
This is a script tag for all ``ContentRendererInclusionHook`` hooks that implement a
render_to_html() method - this is used in in any template to
register any content renderers with the frontend so that they can be dynamically loaded
on demand.
Generates script tags for all ``ContentViewerHook`` hooks.
Used in templates to register content viewers with the frontend
so they can be dynamically loaded on demand.

:return: HTML of script tags to insert into template
"""
return hooks.ContentRendererHook.html()
return hooks.ContentViewerHook.html()
Loading