Content Filters Pipeline
A composable server-side text-transformation pipeline that mirrors Moodle's
format_text()
behaviour — multilang, URL-to-link, emoticons, media embedding, H5P placeholders, glossary
auto-linking, algebra/TeX normalisation, and MathJax wrapping — applied to every
Moodle-sourced HTML/text field before it reaches the JSON response.
5W Overview
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.
- 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
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.
app/Shared/Filters/— pipeline, registry, context, enum, contractsapp/Shared/Filters/Concrete/— 9 concrete filter implementationsapp/Shared/Filters/Support/— HtmlSegmenter, LanguageResolverapp/Shared/Filters/Glossary/— DTO, repository, cacheapp/Shared/Http/Concerns/AppliesContentFilters— opt-in trait- Wired into 8 resources across Course and Calendar modules
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
Flowchart
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
- 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 onGlossaryConceptProviderInterface; 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
- 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 viaconfig()helper
- PHP 8.3 features —
final readonlyDTOs (FilterContext, GlossaryConceptDTO),enum TextFormat: int, constructor promotion, named arguments, match expressions, typed properties throughout - Laravel-first —
Cache::remember(),Collection::map(), no rawarray_*functions;config()notenv()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;
@throwswhere applicable;@param/@returnonly when non-obvious - Constructor injection everywhere — No
app()orresolve()in filter or support classes; all dependencies via constructor - Pint clean — All files pass
./vendor/bin/pint --test; enforced via pre-commit hook
- 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()insetUp(); noDatabase\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 structure —
tests/Unit/Shared/Filters/Concrete/mirrorsapp/Shared/Filters/Concrete/; one test file per implementation class - Coverage target — 90%+ on all filter classes; 100% on FilterPipeline as the critical execution path