zergius-eggstream / amp-converter
Convert rendered HTML pages into AMP-valid HTML. Framework-agnostic with an optional Symfony bridge.
Package info
github.com/zergius-eggstream/php-amp-converter
pkg:composer/zergius-eggstream/amp-converter
Requires
- php: >=8.4
- ext-libxml: *
- ext-mbstring: *
- ext-simplexml: *
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
Suggests
- ext-gd: Only needed to generate the raster PNG fixtures used by the test suite; the package itself does not call any GD function at runtime
- symfony/dependency-injection: Required to use the bundled Symfony bridge
- symfony/http-kernel: Required to use the bundled Symfony bridge (auto-wiring, config tree, bundle)
README
Convert rendered HTML pages into AMP-valid HTML — pure PHP, no Node runtime.
PHP port of the Node-based convert-rendered-to-amp.js shipped with the amp-seo-sites toolset. Designed for build-time conversion of pre-rendered HTML into AMP HTML that passes the official amphtml-validator, with no Node runtime in the conversion path.
Status
Port complete, ready for review. All 13 algorithm stages implemented and exercised end-to-end on a real-world ~119 KB rendered page (covering amp-img, amp-youtube, amp-iframe, amp-bind burger, amp-accordion FAQ, inlined external CSS, JSON-LD); PHP output sits within 0.1 % of the Node reference (113.6 KB vs 113.7 KB, structurally identical AMP). 258 phpunit tests / 513 assertions; PHPStan level 8 clean. CI runs on PHP 8.4. Stage map: doc/port-status.md.
Requirements
- PHP
>=8.4(will be lowered if no 8.x features end up being required) ext-libxml,ext-mbstring,ext-simplexmlext-gdis NOT required at runtime. Raster image dimensions come fromgetimagesize(), which is part of PHP core; SVG parsing usesext-simplexml. GD is only used by the unit tests to generate PNG fixtures, and the affected tests auto-skip when GD is absent. The package works fine on slim production servers that don't have GD loaded — that path is exercised in CI under theno-gdmatrix entry.- No Node.js — pure PHP
Installation
composer require zergius-eggstream/amp-converter
Once the maintainer of the consuming projects forks/republishes the package under their preferred vendor, that require line points at the new name (the namespace stays AmpConverter\ so call sites don't change).
Usage
use AmpConverter\AmpConverter; $result = AmpConverter::createDefault()->convert( $renderedHtml, $siteRoot, canonicalUrl: 'https://example.com/the-page', ); file_put_contents($outputPath, $result->html); foreach ($result->warnings as $w) { error_log("amp-converter: $w"); }
Arguments:
| Name | Required | Default | Meaning |
|---|---|---|---|
$renderedHtml |
yes | — | the rendered HTML to convert |
$siteRoot |
yes | — | absolute path to the site directory (root of on-disk assets) |
$canonicalUrl |
no | null |
absolute URL of the non-AMP (canonical) version of the page. When provided, AMP page emits <link rel="canonical" href="$canonicalUrl"> and replaces any existing canonical link. When null, falls back to a relative self-reference href="./" so the package stays drop-in for hosts that don't compute absolute URLs at build time. |
$assetsBaseDir |
no | 'public' |
subdirectory under $siteRoot where assets live. The default matches the common public/-as-document-root convention; pass an empty string for a flat layout, or a custom folder for non-standard layouts. |
The package is intentionally agnostic about where the host gets canonicalUrl from (TSV, per-site config, request data, hard-coded mapping, …) — that's a host concern. The package just emits what it was given.
ConversionResult exposes:
html: string— the converted AMP HTML.usedComponents: list<string>— AMP custom components actually emitted (amp-img,amp-youtube,amp-iframe,amp-bind,amp-accordion, …). The pipeline emits a<script async custom-element="…">for each one (exceptamp-img, which ships with v0.js).warnings: list<string>— non-fatal issues encountered (unresolvable image, dropped CSS block, malformed tag, …). The host project decides whether to log, surface or ignore them.
Symfony integration
The package is framework-agnostic. A typical Symfony host wires it through a thin one-line autowired service:
namespace App\Renderer; use AmpConverter\AmpConverter as Lib; readonly class AmpConverter { public function convert(string $renderedHtml, string $siteRoot): string { return Lib::createDefault()->convert($renderedHtml, $siteRoot)->html; } }
If a future iteration ships a Symfony Bundle for zero-config auto-wiring (Bridge/Symfony/AmpConverterBundle), it will be additive — the framework-agnostic core won't change.
Architecture
The converter is an ordered pipeline of transformers. Each transformer implements Transformer::apply(string $html, Context $ctx): string and owns one feature area; Context carries cross-transformer state (siteRoot, used components, warnings, detected FAQ classes, font @imports collected from CSS, …).
input HTML
│
▼
┌──────────────────────────┐
│ MaskSnippets │ preserve Twig/PHP dynamics behind opaque placeholders
├──────────────────────────┤
│ CssAggregation │ inline local <link rel=stylesheet>, merge <style> blocks
├──────────────────────────┤
│ CssProcessing │ HTML-entity decode, font @import extract, strip
│ │ !important / @import / @charset, vendor-media,
│ │ broken --vars
├──────────────────────────┤
│ ImgToAmpImg │ <img> → <amp-img> with layout pick (fixed/intrinsic/
│ │ responsive/fill) + logo/avatar heuristics
├──────────────────────────┤
│ IframeConversion │ YouTube → <amp-youtube>; other → <amp-iframe>
│ │ (responsive / fixed-height / fill); <canvas> dropped
├──────────────────────────┤
│ FormConversion │ <form> → <div data-was-form>, submit → amp-bind tap
├──────────────────────────┤
│ DefensiveSourceFixes │ script strip (preserves JSON-LD), on*= strip,
│ │ URL typos, duplicate doctype / meta / head / body,
│ │ table border, rel/class dedupe, alt/loading guards,
│ │ preload strip, oversized inline style
├──────────────────────────┤
│ BurgerToAmpBind │ 3-tier detection (aria-controls / class+nav /
│ │ nav-driven) + CSS-pair guard (5 hidden / 5 shown)
├──────────────────────────┤
│ FaqToAccordion │ 4 variants (container + dl + sibling Question +
│ │ hN+p) + CSS post-process (accordion patch,
│ │ specificity bump, question-class defaults)
├──────────────────────────┤
│ AutoContrastVars │ resolve --X:auto via YIQ luma; fallback strip
├──────────────────────────┤
│ FontImportInjection │ emit <link rel=stylesheet> for collected font CDNs
├──────────────────────────┤
│ AmpRuntimeInjection │ <html ⚡>, v0.js, custom-element scripts (sorted),
│ │ boilerplate, canonical, http-equiv→charset,
│ │ noscript guard
├──────────────────────────┤
│ PurgeCss │ shrink <style amp-custom> (60 KB threshold);
│ │ recursive @media, @font-face/@keyframes preserved
├──────────────────────────┤
│ UnmaskSnippets │ restore the dynamics from step 1
└──────────────────────────┘
│
▼
ConversionResult { html, usedComponents, warnings }
Replace the default pipeline by constructing AmpConverter directly:
use AmpConverter\AmpConverter; use AmpConverter\Transformer\ImgToAmpImg; use AmpConverter\PhpSnippets\MaskSnippets; use AmpConverter\PhpSnippets\UnmaskSnippets; $converter = new AmpConverter([ new MaskSnippets(), new ImgToAmpImg(), new MyCustomTransformer(), new UnmaskSnippets(), ]);
Error handling
Strict but graceful: when the converter cannot transform a fragment (unparseable <img> tag, malformed CSS block, missing image dimensions), it removes the fragment and records a warning rather than throwing. The whole page still converts. Build pipelines decide what to do with warnings (log / fail / ignore). Exceptions are reserved for unrecoverable programmer errors (e.g. invalid pipeline configuration).
Testing
composer test # phpunit composer phpstan # static analysis
Layout:
tests/Unit/<Area>/<Class>Test.php— per-rule unit tests for every transformer; each spec rule has its own positive + negative test.tests/Regression/EndToEndSmokeTest.php— end-to-end smoke test on a real rendered page. Reads the fixture directory from theAMP_CONVERTER_SMOKE_FIXTURE_DIRenvironment variable; auto-skips when the variable is unset or the path is missing, so CI just runs the unit tests.
CI is GitHub Actions over PHP 8.4 with the libxml, mbstring, simplexml, gd extensions; no Node required.
License
MIT.