yellow-twins / fluid-lens
Static analyzer for Fluid templates: finds duplicated markup that should become Partials, plus accessibility and best-practice sniffs.
Requires
- php: ^8.1
- ext-dom: *
- ext-json: *
- masterminds/html5: ^2.9
- symfony/console: ^6.4 || ^7.0
Requires (Dev)
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^10.5
- psalm/plugin-phpunit: ^0.19
- squizlabs/php_codesniffer: ^3.10
- vimeo/psalm: ^5.24
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Static analysis for Fluid templates — find markup that should be a Partial or a Fluid component, catch accessibility & best-practice problems, and move hardcoded text into language files, straight from the command line.
fluid-lens reads your Fluid templates and x-rays them: it looks for duplicated
markup structures that should be extracted into reusable Partials, and (from a later
milestone) flags accessibility and best-practice violations — the kind of review you
would otherwise do by hand, one template at a time.
It runs standalone (no TYPO3 instance required) as a Composer tool, and ships an optional TYPO3 command wrapper.
Status
Usable today. fluid-lens finds exact duplicated structures and near-duplicates across your templates, suggests which of them — and which Partials — should become Fluid components (TYPO3 14 / Fluid 5), lints them for accessibility (WCAG) and best-practice problems, and finds hardcoded text and moves it into XLIFF language files — with inline suppression and a baseline for adopting it on an existing project. See the roadmap for what's next.
Why
Fluid is built around Partials so that markup — images, accordions, tiles, list views,
navigations — lives in one place and is reused everywhere. In practice most projects
re-implement the same structures inline across many templates. fluid-lens makes that
duplication visible and measurable, like PHPStan or PHP_CodeSniffer do for PHP.
Requirements
- PHP 8.1+
- ext-dom, ext-json
Installation
composer require --dev yellow-twins/fluid-lens
Or, for development inside this repository, the package lives in
packages/fluid-lens/ and is installed on its own:
cd packages/fluid-lens
composer install
Usage
Analyse for duplicated structures
Scan a file or a whole directory for markup that repeats and should become a Partial:
vendor/bin/fluid-lens analyze path/to/Templates/
Tune the sensitivity, or emit JSON for CI and tooling:
vendor/bin/fluid-lens analyze path/to/Templates/ --min-elements=5 --min-occurrences=3 vendor/bin/fluid-lens analyze path/to/Templates/ --json
The command exits non-zero when duplicates are found, so it can gate a pipeline.
Find near-duplicate structures
Find blocks that are almost identical — differing by a node or an attribute — and could share one Partial with the differences passed as arguments:
vendor/bin/fluid-lens similar path/to/Templates/ vendor/bin/fluid-lens similar path/to/Templates/ --threshold=0.85 --json
Similarity is measured with pq-gram distance, a fast approximation of tree edit distance. Structures are clustered by transitive similarity above the threshold.
Suggest Fluid components (TYPO3 14 / Fluid 5)
Fluid 5 brings real components — <my:teaserHeader title="…" /> with typed
<f:argument>s and an <f:slot />. components tells you which parts of your
templates should become one, and drafts it:
vendor/bin/fluid-lens components packages/ vendor/bin/fluid-lens components packages/ --only=partials --no-draft vendor/bin/fluid-lens components packages/ --json
It combines three signals:
- Partials rendered from several files. Their arguments come from what they
read: declared
f:arguments are kept, every other variable becomes an argument, and the{settings.*}a Partial sees implicitly become explicit arguments. - Markup copied into several files. The copies are lined up node by node; differing values, class names and texts become arguments.
- Similar markup. An element only some copies have becomes optional behind a
boolean argument; a region that differs too much becomes the
<f:slot />. Copies that are the whole content of several Partials become a suggestion to merge those Partials.
Each suggestion comes with its reasons, the caveats of converting it, the proposed arguments with inferred types, a ready-to-copy component file and the call that replaces every occurrence — nothing is written:
#6 TeasersHeader <st:teasersHeader> score 13.2 (high)
similar markup · 2 call site(s) in 2 file(s) · 7 element(s)
Why
- the Partials Event/Teaser/Header (events), Product/Teaser/Header (mollie_cart)
hold the same frame — merge them into one component
- 1 element(s) only some copies have become optional
Caveats
- implicit-settings: reads {settings.listPageUid}; … a component only gets what is passed
Arguments
name type optional default from seen
class string no — class events-teasers-header | artists-teasers-header
variant string yes "" class | main-yellow-light
key string no — attribute events.next.dates | merch.detail.more
pageUid mixed no — attribute {settings.listPageUid} | {settings.listPid.merch}
linkVariant string no — class yellow | purple
showIcon bool yes {true} presence present in 1 of 2
Draft …/Components/TeasersHeader/TeasersHeader.fluid.html
<f:argument name="class" type="string" />
…
<div class="{class}">
<h2 class="pixellari {variant}"> <f:translate key="{key}" extensionName="projectfiles" /> </h2>
<f:link.action pageUid="{pageUid}" class="normal-link {linkVariant}" action="list">
…
<f:if condition="{showIcon}">
<st:icon name="arrow" />
</f:if>
</f:link.action>
</div>
Call (…/Partials/Event/Teaser/Header.html:2)
<st:teasersHeader class="events-teasers-header" key="events.next.dates" pageUid="{settings.listPageUid}" linkVariant="yellow" />
What has to stay a Partial is listed separately with the reason — most notably
f:form.* fields outside their <f:form>, because a component renders with its own
ViewHelper context and the fields would lose the form. Copies that only share their
shape, or would need more arguments than --max-arguments, are not suggested.
See docs/components.md for the signals, the scoring, every
option and the limits.
Move hardcoded text into language files
Languages are often added late, and until then text is written straight into the
templates. i18n lists every such text — text content and the attributes a
visitor reads (title, alt, placeholder, aria-label, submit value, …) —
and --fix moves it into the right XLIFF file, in the language it is written in,
with an f:translate call in its place:
vendor/bin/fluid-lens i18n packages/ # report vendor/bin/fluid-lens i18n packages/ --fix --dry-run # unified diff, nothing written vendor/bin/fluid-lens i18n packages/ --fix --language=de # templates are written in German
<h2>Upcoming events</h2> → <h2><f:translate key="event.list.upcomingEvents" extensionName="events" /></h2> <p>Hello {user.name}!</p> → <p><f:translate key="event.list.hello" extensionName="events" arguments="{0: user.name}" /></p> <button title="Close">×</button> → <button title="{f:translate(key: 'common.close', extensionName: 'events')}">×</button>
An extension's templates go into its Resources/Private/Language/locallang.xlf, a
Content Block's into its own language/labels.xlf. Keys follow the template path
(event.list.…), and a label that already exists with the same text is reused.
With --language=de the text is also written as the <target> of
de.locallang.xlf, so a German site looks the same while English is translated
later. Sentences with inline markup (Please <a>click here</a>), ViewHelper
expressions inside a text and literals in Fluid arrays are reported for a human
instead of being split up. Every rewritten template is scanned again before it is
written. See docs/i18n.md for the rules, the key scheme, every
option and the limits.
Check project-wide consistency
Over time projects drift: the same slider gets copied around, and different
libraries or icon sets creep in. The consistency command runs a set of
project-wide checks and fails when competing implementations are mixed:
vendor/bin/fluid-lens consistency packages/
vendor/bin/fluid-lens consistency packages/ --only=sliders # just one check
vendor/bin/fluid-lens consistency --list-checks
Built-in checks (selectable with --only / --exclude, wildcards allowed):
| Check | Detects |
|---|---|
sliders |
Slider/carousel libraries (Swiper, Slick, Glide, Splide, Owl, Flickity, Keen, Tiny Slider) |
icons |
Icon sets (Font Awesome, Bootstrap Icons, Material, Ionicons, Feather, Remix, Boxicons) |
css |
CSS frameworks (Bootstrap, Tailwind, Bulma, Foundation) via distinctive signatures |
js-framework |
JS interaction frameworks (Alpine, Vue, htmx, Stimulus, Turbo) via their attributes |
lightbox |
Lightbox/gallery libraries (Fancybox, GLightbox, Magnific Popup, Lightgallery, PhotoSwipe) |
animation |
Animation libraries (Animate.css, WOW.js, AOS) |
lazyload |
Lazy-loading strategy (native loading vs lazysizes/lozad/vanilla-lazyload) |
maps |
Map libraries (Leaflet, Mapbox GL, OpenLayers, Google Maps) |
video-player |
Video players (Plyr, Video.js, MediaElement.js, JW Player, Flowplayer) |
grid |
Grid/masonry libraries (Isotope, Masonry, Packery, Muuri) |
tooltip |
Tooltip libraries (Tippy.js, Foundation, hint.css, microtip) |
cookie-consent |
Cookie-consent solutions (Cookiebot, OneTrust, Osano, Klaro, Borlabs) |
namespace-style |
Fluid namespace declared on <html> vs inline {namespace ...} |
render-style |
Partial render as <f:render> tag vs inline {f:render(...)} |
translate-style |
Translation as <f:translate> tag vs inline {f:translate(...)} |
image-approach |
<f:image> vs <f:uri.image> vs raw <img> with a dynamic src |
link-approach |
Fluid link ViewHelper vs raw <a> with a dynamic href |
The last five are Fluid-specific: they compare how the project writes Fluid
(tag vs inline syntax, ViewHelper vs raw markup), not which CSS library it uses.
image-approach/link-approach only count a raw <img>/<a> when its
src/href is a Fluid expression — a static asset or external link is not a
competing approach.
Duplicated markup (e.g. the same slider copied into five templates) is caught
by analyze and similar — extract it into one shared Partial.
Check accessibility (WCAG) and best practices
Scan templates for accessibility and best-practice problems in seconds, instead of opening every page in a browser:
vendor/bin/fluid-lens lint path/to/Templates/ vendor/bin/fluid-lens lint path/to/Templates/ --json
By default the command exits non-zero on any error or warning (notices are
advisory). Adjust the gate with --fail-on=error|warning|notice|never — for
example fail only on errors while you adopt it:
vendor/bin/fluid-lens lint packages/ --fail-on=error
The rules read the markup as it will be rendered: the array arguments TYPO3's
tag-based ViewHelpers expand into attributes — aria="{label: 'Close'}",
data="{…}" and additionalAttributes="{…}" — are resolved before a rule sees
them, so a link labelled that way counts as labelled.
What it checks statically:
| Rule | Severity | WCAG |
|---|---|---|
wcag.img-alt — image without an alt attribute |
error | 1.1.1 (A) |
wcag.link-name — link with no discernible text (icon-only) |
error | 2.4.4 (A) |
wcag.button-name — button with no discernible text (icon-only) |
error | 4.1.2 (A) |
wcag.duplicate-id — duplicate id in one document |
error | 4.1.1 (A) |
wcag.form-label — control with no way to be labelled |
warning | 4.1.2 (A) |
wcag.label-for — <label for> with no matching id (skips Fluid forms) |
notice | 1.3.1 (A) |
wcag.html-lang — <html> without lang |
warning | 3.1.1 (A) |
wcag.positive-tabindex — tabindex greater than 0 |
warning | 2.4.3 (A) |
wcag.table-header — data table without <th> |
warning | 1.3.1 (A) |
wcag.empty-heading — heading with no text |
warning | 1.3.1 (A) |
wcag.meta-viewport — viewport meta tag that blocks zoom |
warning | 1.4.4 (AA) |
wcag.aria-role — unknown WAI-ARIA role value |
warning | 4.1.2 (A) |
wcag.aria-attr — unknown aria-* attribute (typo) |
warning | 4.1.2 (A) |
wcag.aria-hidden-focusable — aria-hidden on a focusable element |
warning | 4.1.2 (A) |
wcag.aria-expanded-role — aria-expanded on a non-interactive element (broken accordion) |
warning | 4.1.2 (A) |
wcag.tab-selected — role="tab" without aria-selected |
warning | 4.1.2 (A) |
wcag.tablist-tab — role="tablist" without any role="tab" |
warning | 1.3.1 (A) |
wcag.iframe-title — <iframe> without a title |
warning | 4.1.2 (A) |
wcag.media-autoplay — audio/unmuted video that autoplays sound |
warning | 1.4.2 (A) |
wcag.heading-order — heading levels skipped |
warning | 1.3.1 (A) |
wcag.input-image-alt — <input type="image"> without alt |
error | 1.1.1 (A) |
wcag.alt-filename — alt text that is just a file name |
warning | 1.1.1 (A) |
wcag.nested-interactive — a control nested inside another |
warning | 4.1.2 (A) |
wcag.fieldset-legend — <fieldset> without a <legend> |
warning | 1.3.1 (A) |
wcag.list-structure — <ul>/<ol> with a non-<li> child |
warning | 1.3.1 (A) |
wcag.th-empty — empty <th> header cell |
warning | 1.3.1 (A) |
wcag.role-required-attr — ARIA role missing its required state |
warning | 4.1.2 (A) |
wcag.video-captions — <video> without a captions track |
warning | 1.2.2 (A) |
wcag.marquee-blink — <marquee>/<blink> moving content |
warning | 2.2.2 (A) |
wcag.link-generic-text — non-descriptive link text ("read more") |
notice | 2.4.4 (A) |
wcag.lang-valid — invalid lang attribute value |
warning | 3.1.1 (A) |
wcag.label-empty — <label> with no text |
warning | 3.3.2 (A) |
wcag.aria-boolean — boolean ARIA attribute with an invalid value |
warning | 4.1.2 (A) |
wcag.dir-valid — invalid dir attribute value |
warning | 1.3.2 (A) |
wcag.meta-refresh — timed <meta http-equiv="refresh"> |
warning | 2.2.1 (A) |
wcag.summary-details — <summary> outside a <details> |
warning | 1.3.1 (A) |
wcag.scope-value — invalid table-cell scope value |
warning | 1.3.1 (A) |
wcag.abbr-title — <abbr> without a title |
notice | 3.1.4 (AAA) |
wcag.autocomplete-token — invalid autocomplete autofill token |
warning | 1.3.5 (AA) |
wcag.lang-xml-mismatch — lang and xml:lang disagree |
warning | 3.1.2 (AA) |
wcag.accesskey-duplicate — duplicate accesskey in one document |
warning | 2.1.1 (A) |
wcag.target-blank-purpose — target="_blank" without announcing the new tab |
notice | 3.2.5 (AAA) |
wcag.aria-controls-target — aria-controls with no matching id in the template |
notice | 4.1.2 (A) |
wcag.aria-ref-target — aria-labelledby/aria-describedby with no matching id |
notice | 1.3.1 (A) |
wcag.nav-label — multiple navigation landmarks without labels |
notice | 1.3.1 (A) |
markup.picture-img — <picture> without an <img> fallback |
warning | — |
markup.source-srcset — <source> in <picture> without srcset |
warning | — |
style.inline — inline style attribute |
notice | — |
partial.inline-svg — inline <svg> to extract into an Icon partial |
notice | — |
image.prefer-fluid — raw <img> instead of <f:image> |
notice | — |
link.target-blank-rel — target="_blank" without rel="noopener" |
notice | — |
Pick which rules run with --only / --exclude, or see them all (grouped) with
--list-rules. A trailing * matches a prefix:
vendor/bin/fluid-lens lint --list-rules vendor/bin/fluid-lens lint path/ --only=wcag.* # accessibility only vendor/bin/fluid-lens lint path/ --exclude=style.*,partial.* # skip advisory notices vendor/bin/fluid-lens lint path/ --only=wcag.img-alt,wcag.button-name
Honest by design: criteria that genuinely need a rendered page — colour contrast, runtime focus order, reflow — are not silently passed. The report states plainly that they must be verified with a runtime tool (axe, Lighthouse). A static pass is a fast first line of defence, not a replacement for those.
Suppress a block inline
Mark a block that should intentionally stay inline with a comment on the line before it — it is then excluded from every analysis:
{# @fluidlint-ignore this one really is a one-off #}
<div class="special-case">…</div>
Adopt on an existing project with a baseline
Record all current duplication once, then only see new duplication from then on:
# Freeze what exists today vendor/bin/fluid-lens analyze path/to/Templates/ --generate-baseline # Later runs report only duplication that is new or has grown vendor/bin/fluid-lens analyze path/to/Templates/ --baseline=fluid-lens-baseline.json
lint has the same baseline (by rule, file and message, ignoring line numbers) —
freeze today's accessibility debt and only fail on new findings:
vendor/bin/fluid-lens lint path/to/Templates/ --generate-baseline vendor/bin/fluid-lens lint path/to/Templates/ --baseline=fluid-lens-lint-baseline.json
components has one as well — accept today's suggestions and only hear about new
ones (or ones that gained call sites):
vendor/bin/fluid-lens components packages/ --generate-baseline vendor/bin/fluid-lens components packages/ --baseline=fluid-lens-components-baseline.json
consistency has one too — accept today's mix and only fail on new drift. It
records the set of variants each check currently sees, then fails when a variant
appears that the baseline does not list (a third library joining an already-mixed
check, or a previously consistent check becoming mixed):
vendor/bin/fluid-lens consistency path/to/Templates/ --generate-baseline vendor/bin/fluid-lens consistency path/to/Templates/ --baseline=fluid-lens-consistency-baseline.json
Parse a single template
Dump the structural tree the analyzer sees — handy for understanding a finding:
vendor/bin/fluid-lens parse path/to/Template.html vendor/bin/fluid-lens parse path/to/Template.html --json
Running through Composer
You don't have to call the binary directly. In a project that requires
fluid-lens, add a Composer script referencing the command by name — Composer puts
vendor/bin on the path for scripts:
{
"scripts": {
"lint:fluid": "fluid-lens analyze packages/",
"lint:fluid-similar": "fluid-lens similar packages/"
}
}
composer lint:fluid composer exec fluid-lens -- analyze packages/ # ad-hoc, without a script
This repository itself ships composer analyze, composer similar,
composer lint, composer components and composer i18n shortcuts (see composer.json), e.g. composer analyze -- path/.
Configuration
Instead of repeating options on every run, drop a fluid-lens.php in your project
root (auto-discovered, or point at it with --config). Command-line options always
win over the file, which in turn wins over the built-in defaults.
<?php // fluid-lens.php return [ 'paths' => ['packages/'], // scanned when no path is given 'exclude' => ['*/Tests/*'], // glob patterns of files to skip 'lint' => ['exclude' => ['style.inline']], // or 'only' => [...], 'failOn' => 'error' 'analyze' => ['minElements' => 3, 'minOccurrences' => 2], 'similar' => ['threshold' => 0.85, 'minElements' => 4], 'components' => ['minFiles' => 2, 'maxArguments' => 6, 'only' => ['partials', 'clones', 'similar']], ];
With that in place, fluid-lens lint (no arguments) scans the configured paths with
the configured rules. A ready-to-copy fluid-lens.dist.php ships
with the package.
Command reference
All scanning commands accept --config, skip files matching --exclude-path
(comma-separated globs, adding to the configured exclude), and, when no path is
given, fall back to the configured paths.
| Command | Purpose | Key options |
|---|---|---|
analyze |
Find exact duplicated structures | --min-elements, --min-occurrences, --baseline, --generate-baseline, --json |
similar |
Find near-duplicate structures | --threshold, --min-elements, --json |
lint |
Check accessibility (WCAG) & best practices | --only, --exclude, --fail-on, --baseline, --generate-baseline, --list-rules, --json, --sarif |
consistency |
Flag mixed slider/icon/other implementations | --only, --exclude, --list-checks, --baseline, --generate-baseline, --json |
components |
Suggest Fluid components, with drafts | --only, --min-occurrences, --min-files, --min-elements, --max-arguments, --threshold, --min-shared, --limit, --no-draft, --baseline, --generate-baseline, --json |
i18n |
Find hardcoded text, move it into XLIFF files | --fix, --dry-run, --language, --source-language, --style, --file, --min-letters, --baseline, --generate-baseline, --json, --sarif |
parse |
Dump one template's structural node tree | --json |
Exit codes
0— no findings (or all findings covered by the baseline)1— findings were reported, or the path contained no templates
This lets any command gate a CI pipeline: a clean run passes, new duplication fails.
Using it in TYPO3
The standalone binary works inside any TYPO3 project out of the box, and native
vendor/bin/typo3 fluidlens:* commands are available as an option. See
docs/typo3.md.
How it works
Fluid is not valid XML, and Fluid itself only parses ViewHelpers — the surrounding
HTML is opaque text to it. To reason about structure, fluid-lens parses templates
with a tolerant HTML5 parser and:
- keeps Fluid ViewHelper tags (
<f:image>,<f:render>, …) as ordinary elements, - treats
{expressions}as opaque text, - normalises Fluid's XML-style self-closing tags (
<f:image ... />) so they don't wrongly swallow their following siblings the way an HTML5 parser would.
The result is a small, framework-agnostic node tree (src/Parser/Node.php) that later
stages fingerprint and compare.
Roadmap
Milestone 1 — Parser foundation ✅
Parse a template into a node tree; parse command with text and JSON output.
Milestone 2 — Exact clones ✅ (current)
Canonical structure hash (ignoring class values, text and variables) groups
identical structures across templates; the analyze command reports them with a
structural preview, suppressing groups fully contained in a larger one.
Milestone 3 — Near-duplicates ✅ (current)
pq-gram similarity catches structures that differ only slightly, clustered by a
divergence score; the similar command reports each cluster of variants.
Milestone 4 — Ignores & baseline ✅ (current)
{# @fluidlint-ignore #} inline suppression and a PHPStan-style baseline
(--generate-baseline / --baseline) for adopting the tool on existing projects.
Milestone 5 — TYPO3 command wrapper + docs ✅
Native fluidlens:* TYPO3 commands (Configuration/Commands.php) and full docs.
Sniffs — accessibility & best practices ✅
The lint command ships a WCAG markup module (missing alt, icon-only links,
duplicate ids, unlabelled controls, heading jumps, tables without headers, …) plus
best-practice sniffs, with criteria that need a rendered page reported as
needs runtime check rather than silently passed.
Fluid components ✅
The components command suggests Fluid 5 components from frequently rendered
Partials, copied and similar markup — with proposed arguments, an optional-element
and slot analysis, a draft and the replacing call for every occurrence.
Hardcoded text → language files ✅
The i18n command finds text written straight into templates and moves it into
the extension's (or Content Block's) XLIFF file, replacing it with f:translate —
byte-exact, with a diff preview and a self-check.
Next — more sniffs: accordions/tiles/navigation component patterns, <picture>
best practice, expanded ARIA validation.
Later — Auto-fix: write the suggested Partial or component file and replace the
occurrences with the <f:render> or component call.
Continuous integration
Every command exits non-zero on findings, so it gates a pipeline out of the box:
vendor/bin/fluid-lens analyze packages/ --baseline=fluid-lens-baseline.json vendor/bin/fluid-lens lint packages/ --exclude=style.inline,partial.inline-svg
lint --sarif emits SARIF 2.1.0, so accessibility findings show up inline on pull
requests via GitHub code scanning:
- name: Fluid accessibility lint run: vendor/bin/fluid-lens lint packages/ --sarif > fluid-lens.sarif || true - uses: github/codeql-action/upload-sarif@v3 with: sarif_file: fluid-lens.sarif
Quality gates
fluid-lens holds itself to the standard it enforces:
composer qa # phpcs (PSR-12) + PHPStan (level 7) + Psalm (level 6) + PHPUnit
License
GPL-2.0-or-later