Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions .github/workflows/build_whl.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ on:
tar-file-name:
description: "TAR file name"
value: ${{ jobs.build_whl.outputs.tar-file-name }}
sandbox-static-artifact:
description: "Artifact name for the collected sandbox static files"
value: sandbox-static
permissions:
contents: read
jobs:
Expand Down Expand Up @@ -64,6 +67,15 @@ jobs:
key: cext-${{ hashFiles('requirements/cext.txt', 'requirements/cext_noarch.txt') }}
- name: Build Kolibri
run: make dist
# Collected here rather than from the installed whl at release time: the built
# static tree is already on disk, and running it on every build means a breakage
# surfaces in PR CI rather than mid-release.
- name: Collect sandbox static files
run: pnpm run collect-sandbox-static dist/static --clear
- uses: actions/upload-artifact@v7

@rtibblesbot rtibblesbot Jul 24, 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.

nitpick: the artifact is uploaded on every caller, but only the release consumes it.

build_whl.yml is called from release_kolibri.yml, pr_build_kolibri.yml and warm_build_cache.yml. The rationale in the step comment above — surfacing a breakage in PR CI rather than mid-release — is served by the collect step; the upload only matters for the release path. Gating it (if: github.event_name == 'release', or an input from the caller) keeps the early-warning property without storing the tree on every PR push and every develop build.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I disagree - making sure the full collect + upload works is important. Leaving it as is.

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.

Understood — deferring to your call. Withdrawing the nitpick.

with:
name: sandbox-static
path: dist/static
- name: Get WHL filename
id: get-whl-filename
run: echo "whl-file-name=$(basename dist/*.whl)" >> "$GITHUB_OUTPUT"
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/dependency_updates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
# Get the latest commit from the API response
latest_commit = "${{ fromJson(steps.get-commit.outputs.data).commit.sha }}".strip()
# Check stored commit
commit_file = Path('packages/kolibri-sandbox/.h5p-commit-sha')
commit_file = Path('kolibri/plugins/h5p_viewer/h5p_build/.h5p-commit-sha')
stored_commit = ""
if commit_file.exists():
stored_commit = commit_file.read_text().strip()
Expand Down Expand Up @@ -64,16 +64,16 @@ jobs:
if: steps.check-commit.outputs.changed == 'true'
run: |
# Update the commit file
echo "${{ steps.check-commit.outputs.latest_commit }}" > packages/kolibri-sandbox/.h5p-commit-sha
echo "${{ steps.check-commit.outputs.latest_commit }}" > kolibri/plugins/h5p_viewer/h5p_build/.h5p-commit-sha

# Run your script here
pnpm --filter kolibri-sandbox run build-h5p
pnpm --filter kolibri-h5p-viewer-plugin run build-h5p
- name: Check for actual build changes
if: steps.check-commit.outputs.changed == 'true'
id: check-build-changes
run: |
# Stage only the build output files, excluding the commit SHA file
git add kolibri/core/content/static/h5p packages/kolibri-sandbox/h5p_build.json
git add kolibri/plugins/h5p_viewer/static/h5p kolibri/plugins/h5p_viewer/h5p_build/h5p_build.json
if git diff --cached --quiet; then
echo "has_build_changes=false" >> "$GITHUB_OUTPUT"
else
Expand Down
13 changes: 5 additions & 8 deletions .github/workflows/release_kolibri.yml
Original file line number Diff line number Diff line change
Expand Up @@ -193,14 +193,11 @@ jobs:
destination: '${{ secrets.BCK_PRERELEASE_BUILD_ARTIFACT_GCS_BUCKET }}'
parent: false
process_gcloudignore: false
- name: Unzip content static files from whl file
run: |
unzip dist/${{ needs.whl.outputs.whl-file-name }} 'kolibri/core/content/static/*' -d static
mv static/kolibri/core/content/static/** static
rm -rf static/kolibri
rm static/**/*.file_size
# Ungzip all .gz files in the static folder
for f in static/**/*.gz; do gunzip -f "$f"; done
- name: Download sandbox static artifact
uses: actions/download-artifact@v8
with:
name: ${{ needs.whl.outputs.sandbox-static-artifact }}
path: static
- name: Upload content static files to BCK bucket
uses: 'google-github-actions/upload-cloud-storage@v3'
with:
Expand Down
8 changes: 5 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,11 @@ js-dist
storage/*
kolibri/content/content_db/*.sqlite3
kolibri/core/content/contentschema/migrations/*
# Check in h5p & bloom specific files
!kolibri/core/content/static/h5p/
!kolibri/core/content/static/bloom/
# Check in h5p & bloom specific files in their respective plugins

@rtibblesbot rtibblesbot Jul 24, 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.

nitpick: the output directory still isn't ignored, and this hunk doesn't match its commit message.

83c414adc7 lists "Update .gitignore for sandbox static output directory", but the only .gitignore change is relocating the h5p/bloom check-in exceptions to their new plugin paths — which belongs with the plugin migration in 114a7ec8f7, not with the cloud-deployment commit. git check-ignore static/foo.js reports not-ignored: kolibri/**/static/* doesn't reach the repo root, so running the documented command locally leaves a few thousand untracked files in git status.

!kolibri/plugins/h5p_viewer/static/h5p/
!kolibri/plugins/bloompub_viewer/static/bloom/

@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: kolibri/plugins/h5p_viewer/h5p_build/vendor/ is now inside the packaged tree and unignored. downloadH5PVendor.js:13 unpacks the H5P PHP library into h5p_build/vendor/h5p and never removes it (the only fs.rm calls are the pre-download wipes). Two consequences that did not exist at packages/kolibri-sandbox/vendor:

  • MANIFEST.in has graft kolibri/plugins with prunes only for frontend and node_modules, so a developer who ran the documented build-h5p before make dist ships the vendored library in the sdist/wheel.
  • dependency_updates.yml:89-99 hands the tree to create-pull-request with no add-paths, so the directory rides along in the bot's PR.

One line next to the existing h5p/bloom entries closes both.

# H5P PHP library unpacked by build-h5p; only its build output is checked in
kolibri/plugins/h5p_viewer/h5p_build/vendor/

# virtual environment
venv/
Expand Down
4 changes: 3 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
exclude: (\.git/|\.tox/|\.venv/|build/|static/(?!assets/fonts)|dist/|node_modules/|kolibripip\.pex)
# `build/` matches a whole path segment: unanchored it also swallowed the hand-written
# sources in the kolibri-build package and the h5p_viewer h5p_build scripts.
exclude: (\.git/|\.tox/|\.venv/|(^|/)build/|static/(?!assets/fonts)|dist/|node_modules/|kolibripip\.pex)
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ recursive-exclude kolibri/* *pyo
prune kolibri/core/frontend
prune kolibri/core/node_modules
prune kolibri/plugins/*/frontend
prune kolibri/plugins/*/h5p_build
prune kolibri/plugins/*/node_modules
exclude kolibri/*/buildConfig.js
exclude kolibri/plugins/*/buildConfig.js
Expand Down
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
4 changes: 3 additions & 1 deletion docs/frontend_architecture/single_page_apps.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ This will create a navigation component which will be registered to appear in th
Content viewers
---------------

A special kind of Kolibri Module is dedicated to rendering particular content types. All content renderers should extend the ``ContentViewer`` class found in the `kolibri-viewer` package as the default export. In addition, rather than subclassing the ``WebpackBundleHook`` class, content renderers should be defined in the Python code using the ``ContentRendererHook`` class defined in ``kolibri.content.hooks``. In addition to the standard options for the ``WebpackBundleHook``, the ``ContentRendererHook`` also requires a ``presets`` tuple listing the format presets that it will render.
A special kind of Kolibri Module is dedicated to viewing particular content types. All content viewers should extend the ``ContentViewer`` class found in the `kolibri-viewer` package as the default export. In addition, rather than subclassing the ``WebpackBundleHook`` class, content viewers should be defined in the Python code using the ``ContentViewerHook`` class defined in ``kolibri.core.content.hooks``. In addition to the standard options for the ``WebpackBundleHook``, the ``ContentViewerHook`` also requires a ``presets`` tuple listing the format presets that it will handle.

For content that requires sandboxed execution (HTML5 apps, H5P, Bloom, etc.), use ``SandboxedContentViewerHook`` instead. This hook requires an additional ``sandbox_handler_id`` property and a corresponding sandbox handler bundle in ``buildConfig.js`` with ``sandbox_handler: true``.

.. automodule:: kolibri.core.content.hooks
:members:
Expand Down
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default [
'packages/kolibri-jest-config/**',
'packages/kolibri-logging/**',
'packages/build_kolibri_package.js',
'kolibri/**/h5p_build/**',
],
rules: CJS_RULES,
},
Expand Down
158 changes: 144 additions & 14 deletions kolibri/core/content/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,39 @@
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 WebpackError
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 +71,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 +111,120 @@ 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

try:
stats = json.loads(
files(self._module_path)
.joinpath("build")
.joinpath("{}_stats.json".format(self.sandbox_handler_unique_id))
.read_text()
)
except OSError as e:
raise WebpackError(

@rtibblesbot rtibblesbot Jul 20, 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.

blocking: This raises WebpackError unconditionally on a missing handler stats file, and it is the direct cause of the red CI. sandbox_handler_urltemplate_html runs during every page render that includes the content-viewer tag. The handler bundle (..._sandbox_handler_stats.json) is a separate build artifact from the plugin's main bundle and is not produced by the frontend build the Python test job runs against, so the file is legitimately absent and the raise bubbles to a 500. That's the failure in test/test_key_urls.py (test_{admin,anonymous,coach,learner,superuser}_responses, test_setup_wizard_not_redirected): WebpackError: ... No such file or directory: ...h5p_viewer/build/...sandbox_handler_stats.json → Internal Server Error: /en/setup/.

Beyond CI, any packaged build that ships a registered SandboxedContentViewerHook without its handler bundle would 500 on every page.

Scope the fail-loud to development, matching the original suggestion: raise under DEVELOPER_MODE, otherwise return {} so the tag is omitted and the page still renders (this was the graceful degradation that kept the prior round green). Then have test_missing_stats_file_raises assert under self.settings(DEVELOPER_MODE=True). Alternatively, if handler bundles are meant to always be present, build them in the test/packaging pipelines — but graceful degradation outside dev is the safer contract.

"Error accessing sandbox handler stats file '{}': {}".format(
self.sandbox_handler_unique_id, e
)
)

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
Loading