diff --git a/.github/workflows/build_whl.yml b/.github/workflows/build_whl.yml index a1a1733eb59..b0b1d0244fe 100644 --- a/.github/workflows/build_whl.yml +++ b/.github/workflows/build_whl.yml @@ -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: @@ -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 + 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" diff --git a/.github/workflows/dependency_updates.yml b/.github/workflows/dependency_updates.yml index 800b31ad1a8..a45066e63cb 100644 --- a/.github/workflows/dependency_updates.yml +++ b/.github/workflows/dependency_updates.yml @@ -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() @@ -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 diff --git a/.github/workflows/release_kolibri.yml b/.github/workflows/release_kolibri.yml index 620c2c031d5..097af636242 100644 --- a/.github/workflows/release_kolibri.yml +++ b/.github/workflows/release_kolibri.yml @@ -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: diff --git a/.gitignore b/.gitignore index 4b478e6d023..3e97743cd4e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 +!kolibri/plugins/h5p_viewer/static/h5p/ +!kolibri/plugins/bloompub_viewer/static/bloom/ +# H5P PHP library unpacked by build-h5p; only its build output is checked in +kolibri/plugins/h5p_viewer/h5p_build/vendor/ # virtual environment venv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 63f0ba23b12..e91f7172619 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/MANIFEST.in b/MANIFEST.in index 9a0bb80dfef..3e965ba405b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -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 diff --git a/docs/frontend_architecture/components.rst b/docs/frontend_architecture/components.rst index aeb85bdfb43..17064870cba 100644 --- a/docs/frontend_architecture/components.rst +++ b/docs/frontend_architecture/components.rst @@ -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. diff --git a/docs/frontend_architecture/single_page_apps.rst b/docs/frontend_architecture/single_page_apps.rst index b38ece7b541..02b25ec92ff 100644 --- a/docs/frontend_architecture/single_page_apps.rst +++ b/docs/frontend_architecture/single_page_apps.rst @@ -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: diff --git a/eslint.config.mjs b/eslint.config.mjs index 9f91868888a..b62c3c74208 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -21,6 +21,7 @@ export default [ 'packages/kolibri-jest-config/**', 'packages/kolibri-logging/**', 'packages/build_kolibri_package.js', + 'kolibri/**/h5p_build/**', ], rules: CJS_RULES, }, diff --git a/kolibri/core/content/hooks.py b/kolibri/core/content/hooks.py index 1f22a294e54..53530f76808 100644 --- a/kolibri/core/content/hooks.py +++ b/kolibri/core/content/hooks.py @@ -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): @@ -63,16 +71,28 @@ 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() @@ -80,11 +100,7 @@ def template_html(self): '{data}'.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, @@ -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): + """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( + "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 - - - diff --git a/kolibri/plugins/bloompub_viewer/kolibri_plugin.py b/kolibri/plugins/bloompub_viewer/kolibri_plugin.py index 8436e8ea814..94f47f241c0 100644 --- a/kolibri/plugins/bloompub_viewer/kolibri_plugin.py +++ b/kolibri/plugins/bloompub_viewer/kolibri_plugin.py @@ -10,6 +10,7 @@ class BloomPubRenderPlugin(KolibriPluginBase): @register_hook -class BloomPubRenderAsset(content_hooks.ContentRendererHook): +class BloomPubRenderAsset(content_hooks.SandboxedContentViewerHook): bundle_id = "main" + sandbox_handler_id = "sandbox_handler" presets = (format_presets.BLOOMPUB,) diff --git a/kolibri/plugins/bloompub_viewer/package.json b/kolibri/plugins/bloompub_viewer/package.json index d9128ca43db..4e273e17aa4 100644 --- a/kolibri/plugins/bloompub_viewer/package.json +++ b/kolibri/plugins/bloompub_viewer/package.json @@ -4,12 +4,15 @@ "private": true, "version": "0.0.1", "dependencies": { + "core-js": "catalog:", "kolibri": "workspace:*", "kolibri-common": "workspace:*", "kolibri-design-system": "catalog:", "kolibri-sandbox": "workspace:*", "kolibri-viewer": "workspace:*", + "kolibri-zip": "workspace:*", "lodash": "catalog:", - "vue": "catalog:" + "vue": "catalog:", + "web-streams-polyfill": "catalog:" } } \ No newline at end of file diff --git a/kolibri/core/content/static/bloom/bloomPlayer-02a77592459cdf0b1bd7.min.js b/kolibri/plugins/bloompub_viewer/static/bloom/bloomPlayer-02a77592459cdf0b1bd7.min.js similarity index 100% rename from kolibri/core/content/static/bloom/bloomPlayer-02a77592459cdf0b1bd7.min.js rename to kolibri/plugins/bloompub_viewer/static/bloom/bloomPlayer-02a77592459cdf0b1bd7.min.js diff --git a/kolibri/core/content/static/bloom/bloomplayer.htm b/kolibri/plugins/bloompub_viewer/static/bloom/bloomplayer.htm similarity index 67% rename from kolibri/core/content/static/bloom/bloomplayer.htm rename to kolibri/plugins/bloompub_viewer/static/bloom/bloomplayer.htm index 6ba5d8a90e8..78ecb9bb4c6 100644 --- a/kolibri/core/content/static/bloom/bloomplayer.htm +++ b/kolibri/plugins/bloompub_viewer/static/bloom/bloomplayer.htm @@ -5,6 +5,12 @@
+ + diff --git a/kolibri/core/content/static/bloom/right_answer-913c37e88e2939122d763361833efd24.mp3 b/kolibri/plugins/bloompub_viewer/static/bloom/right_answer-913c37e88e2939122d763361833efd24.mp3 similarity index 100% rename from kolibri/core/content/static/bloom/right_answer-913c37e88e2939122d763361833efd24.mp3 rename to kolibri/plugins/bloompub_viewer/static/bloom/right_answer-913c37e88e2939122d763361833efd24.mp3 diff --git a/kolibri/core/content/static/bloom/wrong_answer-f96cfc1e0cc2cea2dd523d521d4c8738.mp3 b/kolibri/plugins/bloompub_viewer/static/bloom/wrong_answer-f96cfc1e0cc2cea2dd523d521d4c8738.mp3 similarity index 100% rename from kolibri/core/content/static/bloom/wrong_answer-f96cfc1e0cc2cea2dd523d521d4c8738.mp3 rename to kolibri/plugins/bloompub_viewer/static/bloom/wrong_answer-f96cfc1e0cc2cea2dd523d521d4c8738.mp3 diff --git a/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue b/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue index c3742753054..a9a0c322ae9 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue @@ -4,7 +4,7 @@ :active="mobileEmbedded" @read="$refs.epubViewer.toggleFullscreen()" > -