🗂 5W Overview

📦 What

A pluggable FilterPipeline (FilterPipelineInterface) that chains 9 server-side text filters over Moodle-sourced HTML fields: multilang, urltolink, emoticon, mediaplugin, displayh5p, glossary, algebra, tex, and mathjax. Each filter is a named class implementing FilterInterface and receives a typed FilterContext DTO carrying courseId, userId, language, and TextFormat.

When
  • Server-side, during resource serialization (toArray())
  • After @@PLUGINFILE@@ token resolution runs first
  • Before the JSON response is returned to the client
  • Glossary concept lookups cached per course (5 min TTL, configurable)
  • Only runs when a controller calls ->withFilters() — opt-in
💡 Why

Without filters, students see raw $$x^2$$, emoticon codes, untranslated multilang spans, bare URLs, and unrendered H5P references. Moodle applies these transformations via format_text() on every render. This portal reads Moodle's raw stored content directly and must replicate the same transformations server-side to deliver correctly formatted content.

📍 Where
  • app/Shared/Filters/ — pipeline, registry, context, enum, contracts
  • app/Shared/Filters/Concrete/ — 9 concrete filter implementations
  • app/Shared/Filters/Support/ — HtmlSegmenter, LanguageResolver
  • app/Shared/Filters/Glossary/ — DTO, repository, cache
  • app/Shared/Http/Concerns/AppliesContentFilters — opt-in trait
  • Wired into 8 resources across Course and Calendar modules
⚙️ How

A controller constructor-injects FilterPipelineInterface and LanguageResolver, then calls $resource->withFilters($pipeline, $resolver, $courseId) before returning. Inside toArray() the resource's AppliesContentFilters trait calls $this->applyFilters($text, $format, $request) on each HTML field. The trait builds a FilterContext — resolving user language via 3-step chain (user.lang → Moodle site config → Laravel app.locale) — then passes text through FilterPipeline::process(). The pipeline iterates each registered filter in config/filters.enabled order. Text-walking filters (multilang, urltolink, emoticon, glossary, algebra, tex) use HtmlSegmenter::walk() to skip inside <a>, <script>, .nolink, .filter_mathjax_equation, etc. Glossary lookups are backed by a GlossaryConceptCache wrapping a read-only Eloquent repository over Moodle's glossary tables. The FilterRegistry maps short names to FQNs; AppServiceProvider::registerFilterPipeline() composes the singleton pipeline from config at boot time.

🔄 Sequence Diagram

Full HTTP request lifecycle — content filter path
sequenceDiagram participant Browser participant Middleware participant Controller participant LangResolver as LanguageResolver participant Resource participant Pipeline as FilterPipeline participant Segmenter as HtmlSegmenter participant GCache as GlossaryConceptCache participant DB as MySQL Browser->>Middleware: GET /api/v1/courses/{id}/lessons/{id}/pages/{id} Middleware->>Controller: Authenticated + throttle passes note over Controller: Injected: FilterPipelineInterface + LanguageResolver Controller->>Controller: lessonService.showPage(dto) Controller->>Resource: new LessonPageResource(page) Controller->>Resource: withPluginfileResolution(resolver, courseId) note over Resource: @@PLUGINFILE@@ tokens resolved to real URLs Controller->>Resource: withFilters(pipeline, languageResolver, courseId) Controller->>Resource: toResponse(request) Resource->>Resource: toArray(request) — applyFilters(contents, format, req) Resource->>LangResolver: resolve(moodleUser) LangResolver-->>Resource: "en" via 3-step chain Resource->>Resource: new FilterContext(courseId, userId, "en", TextFormat::Html) Resource->>Pipeline: process(html, context) Pipeline->>Pipeline: MultilangFilter.apply — strip non-matching lang spans Pipeline->>Segmenter: walk(html, urlToLink transform) Segmenter-->>Pipeline: URL-linked text (skips inside a, .nolink) Pipeline->>Segmenter: walk(html, emoticon transform) Segmenter-->>Pipeline: emoticon span placeholders Pipeline->>Pipeline: MediapluginFilter.apply — a href mp4 to video tag Pipeline->>Pipeline: DisplayH5pFilter.apply — H5P URL to div placeholder Pipeline->>GCache: forCourse(courseId) GCache->>DB: SELECT glossary_entries (if cache miss, TTL 300s) DB-->>GCache: concept rows GCache-->>Pipeline: Collection of GlossaryConceptDTO Pipeline->>Segmenter: walk(html, glossary link transform) Segmenter-->>Pipeline: auto-linked glossary terms Pipeline->>Pipeline: AlgebraFilter.apply — @@expr@@ to LaTeX Pipeline->>Pipeline: TexFilter.apply — normalise TeX delimiters Pipeline->>Segmenter: walk(html, mathjax wrap transform) Segmenter-->>Pipeline: span.filter_mathjax_equation wrapped Pipeline-->>Resource: filtered HTML string Resource-->>Controller: array with filtered fields Controller-->>Browser: 200 JSON with filtered contents

🗺 Flowchart

Filter pipeline decision paths and error handling
flowchart TD A([HTTP Request]) --> B{Auth middleware} B -- 401 --> ERR1([401 Unauthorized]) B -- passes --> C[Controller method] C --> D[Service: fetch Moodle data from DB] D -- not found --> ERR2([404 Not Found]) D -- found --> E[Build Resource object] E --> F[withPluginfileResolution called first] F --> G[Resolve all PLUGINFILE tokens to real URLs] G --> H{withFilters called?} H -- No --> SKIP[Return raw field value as-is] SKIP --> RESP H -- Yes --> I[applyFilters in toArray] I --> J{text null or empty?} J -- Yes --> SKIP2[Return null or empty] J -- No --> K[Build FilterContext: courseId userId language format] K --> L[FilterPipeline::process] L --> M[MultilangFilter: strip non-matching lang spans] M --> N[UrlToLinkFilter via HtmlSegmenter: wrap bare URLs in anchor] N --> O[EmoticonFilter via HtmlSegmenter: replace codes with span] O --> P[MediapluginFilter: convert audio/video anchors to HTML5 tags] P --> Q[DisplayH5pFilter: convert H5P URL to div placeholder] Q --> R{GlossaryConceptCache: forCourse} R -- cache hit --> S[GlossaryFilter via HtmlSegmenter: auto-link terms] R -- cache miss --> T[DB: load approved glossary entries and aliases] T --> U[Cache result for TTL seconds] U --> S S --> V[AlgebraFilter: normalise algebra syntax to LaTeX] V --> W[TexFilter: normalise all TeX delimiters to standard form] W --> X[MathjaxFilter via HtmlSegmenter: wrap LaTeX in span] X --> RESP RESP([filtered HTML returned to resource]) --> JSON([200 JSON Response]) style ERR1 fill:#ef4444,color:#fff,stroke:none style ERR2 fill:#ef4444,color:#fff,stroke:none style SKIP fill:#1a1d27,color:#64748b,stroke:#2a2d3e style SKIP2 fill:#1a1d27,color:#64748b,stroke:#2a2d3e style JSON fill:#10b981,color:#fff,stroke:none

📁 Files Changed

File Path Layer Description
app/Shared/Filters/TextFormat.php Filter Enum mirroring Moodle's FORMAT_* constants (Moodle=0, Html=1, Plain=2, Markdown=4); provides key(), isHtmlBearing(), and fromIntOrHtml() factory
app/Shared/Filters/FilterContext.php Filter Final readonly DTO: courseId, userId, language, TextFormat; passed through every filter; supports immutable withLanguage() copy method
app/Shared/Filters/Contracts/FilterInterface.php Filter Contract every concrete filter implements: name(): string and apply(string, FilterContext): string
app/Shared/Filters/Contracts/FilterPipelineInterface.php Filter Public pipeline contract bound in the service container; exposes process(string, FilterContext): string
app/Shared/Filters/FilterPipeline.php Filter Default pipeline implementation; iterates injected filters in order, short-circuits on empty input
app/Shared/Filters/FilterRegistry.php Filter Maps canonical short names (e.g. "glossary") to FQNs; used by AppServiceProvider to materialise the pipeline from config/filters.enabled without a large match statement
app/Shared/Filters/Support/HtmlSegmenter.php Support Skip-tag-aware HTML text walker; applies a transform callback only to text nodes outside <a>, <script>, <style>, <select>, <textarea>, <noscript>, <iframe>, and elements with .nolink / .filter_mathjax_equation / .filter_glossary classes
app/Shared/Filters/Support/LanguageResolver.php Support Resolves request language via 3-step chain: authenticated user's lang attribute → Moodle site config → Laravel app.locale fallback
app/Shared/Filters/Glossary/GlossaryConceptDTO.php Glossary Final readonly DTO: entryId, glossaryId, concept, caseSensitive, fullMatch — one instance per glossary term/alias
app/Shared/Filters/Glossary/GlossaryConceptProviderInterface.php Glossary Contract for concept lookup; bound to GlossaryConceptCache in AppServiceProvider; enables swapping for tests
app/Shared/Filters/Glossary/GlossaryConceptRepository.php Repository Loads approved glossary entries + aliases for a given courseId from Moodle's glossary tables via Eloquent; returns Collection of GlossaryConceptDTO
app/Shared/Filters/Glossary/GlossaryConceptCache.php Glossary Wraps GlossaryConceptRepository in Cache::remember("filter:glossary:course:{id}", ttl); default TTL 300 s, configurable via FILTER_GLOSSARY_CACHE_TTL
app/Shared/Filters/Concrete/MultilangFilter.php Filter Strips non-matching <span lang="XX" class="multilang"> blocks; keeps only the block matching context.language; supports legacy <lang> syntax when force_old_syntax=true
app/Shared/Filters/Concrete/UrlToLinkFilter.php Filter Wraps bare https:// and www. URLs in <a class="nolink">; optionally converts image URLs to <img>; skips inside existing anchors via HtmlSegmenter; format-scoped
app/Shared/Filters/Concrete/EmoticonFilter.php Filter Replaces emoticon codes (:-), :eek:, etc.) with <span class="filter_emoticon" data-code="…" aria-label="…"> placeholders; inline_img mode available via env flag
app/Shared/Filters/Concrete/MediapluginFilter.php Filter Converts <a href="*.mp4"> anchors to native HTML5 <video controls>/<audio controls> elements for configured file extensions
app/Shared/Filters/Concrete/DisplayH5pFilter.php Filter Transforms H5P URLs into <div class="filter_h5p_placeholder" data-h5p-src="…"> for frontend mounting; iframe mode available behind FILTER_H5P_MODE env var
app/Shared/Filters/Concrete/GlossaryFilter.php Filter Loads cached concepts for context.courseId; builds combined regex respecting caseSensitive + fullMatch flags; uses HtmlSegmenter to emit <a class="filter_glossary" data-entry-id="…">
app/Shared/Filters/Concrete/AlgebraFilter.php Filter Normalises @@expr@@ and <algebra>expr</algebra> to \(expr\) for MathJax; documents known limitation that non-TeX algebra syntax (e.g. sqrt(x)) is not converted
app/Shared/Filters/Concrete/TexFilter.php Filter Normalises $$…$$, \[…\], <tex>, [tex] delimiters to standard \(…\) (inline) and \[…\] (display) for uniform MathJax consumption
app/Shared/Filters/Concrete/MathjaxFilter.php Filter Wraps each \(…\) and \[…\] in <span class="filter_mathjax_equation"> so frontend MathJax targets precisely; uses HtmlSegmenter to skip existing markers
app/Shared/Models/MoodleGlossary.php Model Read-only Eloquent model over Moodle's glossary table; boolean casts; entries() hasMany relationship to MoodleGlossaryEntry
app/Shared/Models/MoodleGlossaryEntry.php Model Read-only model over glossary_entries; boolean casts; belongsTo MoodleGlossary and hasMany MoodleGlossaryAlias relationships
app/Shared/Models/MoodleGlossaryAlias.php Model Read-only model over glossary_alias; belongsTo MoodleGlossaryEntry; surfaces alias terms as additional concepts for glossary auto-linking
config/filters.php Config Source of truth for enabled filter list (execution order) and per-filter config: embedding modes, URL format scoping, media extensions, glossary cache TTL
.env.example Config Added FILTER_MULTILANG_FORCE_OLD, FILTER_H5P_MODE, FILTER_EMOTICON_MODE, FILTER_H5P_EMBED_BASE_URL, FILTER_EMOTICON_BASE_URL, FILTER_GLOSSARY_CACHE_TTL
app/Shared/Http/Concerns/AppliesContentFilters.php Concern Resource trait providing withFilters() opt-in activation and applyFilters() helper; builds FilterContext from request user; calls pipeline; returns raw text when pipeline not activated
app/Modules/Course/Resources/CourseResource.php Resource Added AppliesContentFilters trait; calls applyFilters on course.summary with summaryformat
app/Modules/Course/Resources/SectionResource.php Resource Added AppliesContentFilters trait; calls applyFilters on section.summary with summaryformat
app/Modules/Course/Resources/ModuleDetailResource.php Resource Added AppliesContentFilters trait; calls applyFilters on page.intro, page.content, label.intro, and url.intro fields with their respective format columns
app/Modules/Course/Resources/LessonResource.php Resource Added AppliesContentFilters trait; calls applyFilters on lesson.intro with introformat
app/Modules/Course/Resources/LessonPageResource.php Resource Added AppliesContentFilters trait; calls applyFilters on page.contents with contentsformat
app/Modules/Course/Resources/LessonPageCollectionResource.php Resource Propagates withFilters() down to each individual LessonPageResource in the collection
app/Modules/Calendar/Resources/CalendarEventResource.php Resource Added AppliesContentFilters trait; calls applyFilters on event.description with event.format
app/Modules/Course/Controllers/CourseController.php Controller Injects FilterPipelineInterface + LanguageResolver; calls withFilters() on CourseResource with courseId before returning
app/Modules/Course/Controllers/SectionController.php Controller Injects FilterPipelineInterface + LanguageResolver; calls withFilters() on SectionResource with courseId
app/Modules/Course/Controllers/ModuleController.php Controller Injects FilterPipelineInterface + LanguageResolver; calls withFilters() on ModuleDetailResource with courseId
app/Modules/Course/Controllers/LessonController.php Controller Injects FilterPipelineInterface + LanguageResolver; calls withFilters() on LessonResource, LessonPageResource, and LessonPageCollectionResource with courseId
app/Modules/Calendar/Controllers/EventController.php Controller Injects FilterPipelineInterface + LanguageResolver; calls withFilters() on CalendarEventResource
app/Providers/AppServiceProvider.php Provider Added registerFilterPipeline() method; registers HtmlSegmenter, LanguageResolver, GlossaryConceptProviderInterface (bound to GlossaryConceptCache), all 9 filter singletons, and FilterPipelineInterface (composed from config('filters.enabled') via FilterRegistry)
tests/Unit/Shared/Filters/FilterPipelineTest.php Test Unit: correct execution order, context propagation through filters, empty-input short-circuit
tests/Unit/Shared/Filters/Support/HtmlSegmenterTest.php Test Unit: skip-tag set (a, script, style, select, textarea, noscript, iframe), class-based skipping, nested tags, malformed HTML edge cases
tests/Unit/Shared/Filters/Support/LanguageResolverTest.php Test Unit: user lang priority, Moodle site config fallback, app.locale final fallback
tests/Unit/Shared/Filters/Concrete/MultilangFilterTest.php Test Unit: current-language block selection, other-language stripping, legacy <lang> syntax support
tests/Unit/Shared/Filters/Concrete/UrlToLinkFilterTest.php Test Unit: bare URL wrapping, image embedding, format-scoping, skip-inside-anchor via HtmlSegmenter
tests/Unit/Shared/Filters/Concrete/EmoticonFilterTest.php Test Unit: emoticon code replacement, placeholder vs inline_img modes, aria-label output
tests/Unit/Shared/Filters/Concrete/MediapluginFilterTest.php Test Unit: video tag generation, audio tag generation, unsupported extension passthrough
tests/Unit/Shared/Filters/Concrete/DisplayH5pFilterTest.php Test Unit: placeholder div output, iframe mode, non-H5P URL passthrough
tests/Unit/Shared/Filters/Concrete/GlossaryFilterTest.php Test Unit: term auto-linking, caseSensitive/fullMatch flag handling, skip-inside-anchor protection
tests/Unit/Shared/Filters/Concrete/AlgebraFilterTest.php Test Unit: @@expr@@ to \(expr\) conversion, algebra tag conversion, passthrough for unrecognised syntax
tests/Unit/Shared/Filters/Concrete/TexFilterTest.php Test Unit: delimiter normalisation across $$, \[…\], <tex>, [tex] variants, inline vs display output
tests/Unit/Shared/Filters/Concrete/MathjaxFilterTest.php Test Unit: \(…\) and \[…\] wrapping in span.filter_mathjax_equation, skip inside existing markers
tests/Feature/Course/LessonPageFilteringTest.php Test Feature: end-to-end GET lesson page returns contents field processed through full filter pipeline with DB-seeded data

📐 Rules Applied

Architecture
  • Layer separation — Controllers only inject pipeline + resolver and call withFilters(); all transformation logic lives in filter classes (Shared layer)
  • Shared subsystem pattern — All filter code is in app/Shared/Filters/ as a cross-cutting concern; no module-specific filter classes
  • Moodle read-only models — MoodleGlossary*, MoodleGlossaryEntry, MoodleGlossaryAlias extend MoodleReadModel; write methods throw exceptions; no write access
  • Interface-driven DI — Controllers depend on FilterPipelineInterface; GlossaryFilter depends on GlossaryConceptProviderInterface; no concrete coupling
  • Config as source of truth — Filter execution order is defined in config/filters.php, not hardcoded in any class or provider
  • Pluginfile before filters — Architecture rule: @@PLUGINFILE@@ tokens must be real URLs before mediaplugin and urltolink scan — withPluginfileResolution() always called first
  • Response envelope unchanged — Filters operate transparently on field values; all existing { "data": … } structures preserved
Security
  • No raw SQL — GlossaryConceptRepository uses Eloquent query builder with parameter binding; no string interpolation in queries
  • No mass-assignment on Moodle data — Read-only models guard against create/update/save; explicit column selection in all repository queries
  • HTML not generated from user input — Filter inputs are Moodle's stored content only; no student-supplied text is injected into the pipeline
  • Env-gated base URLs — H5P iframe and emoticon inline_img modes require explicit env vars; default is safe placeholder mode — no Moodle URL construction server-side
  • Secrets via config() — All env references live in config/filters.php; filter classes read only via config() helper
Coding Style
  • PHP 8.3 featuresfinal readonly DTOs (FilterContext, GlossaryConceptDTO), enum TextFormat: int, constructor promotion, named arguments, match expressions, typed properties throughout
  • Laravel-firstCache::remember(), Collection::map(), no raw array_* functions; config() not env() in filter classes
  • final by default — Every new class is final; only contracts are interfaces designed for external implementation
  • Enums over constants — TextFormat enum replaces Moodle's bare integer FORMAT_* constants
  • PHPDoc on all classes and public methods — Brief class docblock + method docblock; @throws where applicable; @param/@return only when non-obvious
  • Constructor injection everywhere — No app() or resolve() in filter or support classes; all dependencies via constructor
  • Pint clean — All files pass ./vendor/bin/pint --test; enforced via pre-commit hook
Testing
  • TDD cycle followed — Each filter class built Red-Green-Refactor; failing unit test written before implementation
  • No Moodle model factories — Glossary-related tests seed via DB::table()->insert() in setUp(); no Database\Factories\Moodle*Factory.php
  • AAA pattern — All test methods follow Arrange-Act-Assert with one assertion concept per test
  • test_it_ naming — All test methods use snake_case test_it_ prefix describing the behaviour under test
  • Laravel fakes only — Feature tests use RefreshDatabase and real pipeline; no Eloquent or FilterPipeline mocking in feature tests
  • Mirrors app structuretests/Unit/Shared/Filters/Concrete/ mirrors app/Shared/Filters/Concrete/; one test file per implementation class
  • Coverage target — 90%+ on all filter classes; 100% on FilterPipeline as the critical execution path