Skip to content

Commit 61ea243

Browse files
committed
fix: add xslt polyfill so forum continues to boot on browsers without XSLT
Backports the 2.x polyfill (#4359) for the 1.8 line. Chrome disabled XSLT in Beta from 145 (Dec 2025) and Stable from 158 (Nov 2026); without the polyfill, s9e/TextFormatter's `new XSLTProcessor` at parser-init time throws a ReferenceError that prevents forum.js from booting on those browsers. A ~200-byte detector is emitted into the document <head>. It tries to construct an XSLTProcessor; on failure it document.write()s a script tag pointing at the polyfill bundle. document.write of a script tag during HTML parsing blocks the parser until the polyfill loads, so window.XSLTProcessor is in place before forum.js runs. The polyfill is published into the flarum-assets disk by assets:publish and resolved through the disk's own ->url(), so installs that serve assets from a remote bucket / CDN get the right URL. To stay patch-release-safe, the FilesystemFactory is resolved via the container inside makeXsltPolyfillLoader() rather than added to the Document constructor — same pattern Document already uses for TitleDriverInterface.
1 parent a2260e4 commit 61ea243

10 files changed

Lines changed: 408 additions & 2 deletions

File tree

1.26 MB
Binary file not shown.

framework/core/js/dist/xslt-polyfill/package.json

Lines changed: 42 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

framework/core/js/dist/xslt-polyfill/xslt-polyfill.min.js

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

framework/core/js/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"nanoid": "^3.1.30",
2020
"punycode": "^2.1.1",
2121
"textarea-caret": "^3.1.0",
22-
"throttle-debounce": "^3.0.1"
22+
"throttle-debounce": "^3.0.1",
23+
"xslt-polyfill": "^1.0.21"
2324
},
2425
"devDependencies": {
2526
"@flarum/jest-config": "^1.0.0",
@@ -44,7 +45,8 @@
4445
},
4546
"scripts": {
4647
"dev": "webpack --mode development --watch",
47-
"build": "webpack --mode production",
48+
"build": "webpack --mode production && yarn run copy-xslt-polyfill",
49+
"copy-xslt-polyfill": "rm -rf dist/xslt-polyfill && mkdir -p dist/xslt-polyfill/dist && SRC=$(node -e \"console.log(require('path').dirname(require.resolve('xslt-polyfill/package.json')))\") && cp $SRC/xslt-polyfill.min.js dist/xslt-polyfill/ && cp $SRC/dist/xslt-wasm.js dist/xslt-polyfill/dist/ && cp $SRC/package.json dist/xslt-polyfill/",
4850
"analyze": "cross-env ANALYZER=true yarn run build",
4951
"format": "prettier --write src",
5052
"format-check": "prettier --check src",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Formatter;
11+
12+
use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory;
13+
use RuntimeException;
14+
15+
class XsltPolyfill
16+
{
17+
/**
18+
* Resolve the public URL of the published xslt-polyfill bundle, if it
19+
* can be served by the configured assets disk.
20+
*
21+
* Returns null when the disk has no public URL (e.g. an in-memory test
22+
* disk), in which case callers should skip the polyfill entirely.
23+
*
24+
* @param FilesystemFactory $filesystemFactory
25+
* @return string|null
26+
*/
27+
public static function publicUrl(FilesystemFactory $filesystemFactory)
28+
{
29+
try {
30+
$url = $filesystemFactory->disk('flarum-assets')->url('xslt-polyfill/xslt-polyfill.min.js');
31+
} catch (RuntimeException $e) {
32+
return null;
33+
}
34+
35+
if (($version = self::version()) !== null) {
36+
$url .= '?v='.$version;
37+
}
38+
39+
return $url;
40+
}
41+
42+
/**
43+
* Locate the vendored xslt-polyfill bundle inside core's js/dist.
44+
*
45+
* The polyfill is copied here from node_modules at `yarn build` time
46+
* (see the copy-xslt-polyfill script in framework/core/js/package.json),
47+
* so it ships as part of the published flarum/core package — operators
48+
* never need to run yarn themselves.
49+
*
50+
* @return string|null
51+
*/
52+
public static function findSource()
53+
{
54+
$sourceDir = __DIR__.'/../../js/dist/xslt-polyfill';
55+
56+
if (file_exists($sourceDir.'/xslt-polyfill.min.js')) {
57+
return $sourceDir;
58+
}
59+
60+
return null;
61+
}
62+
63+
/**
64+
* Read the polyfill version from its package.json, used as a cache-bust
65+
* query string on the published URL so browsers pick up new versions
66+
* without waiting for heuristic revalidation.
67+
*
68+
* @return string|null
69+
*/
70+
public static function version()
71+
{
72+
$sourceDir = self::findSource();
73+
if ($sourceDir === null) {
74+
return null;
75+
}
76+
77+
$packageJson = $sourceDir.'/package.json';
78+
if (! file_exists($packageJson)) {
79+
return null;
80+
}
81+
82+
$data = json_decode(file_get_contents($packageJson), true);
83+
84+
return is_array($data) && isset($data['version']) && is_string($data['version'])
85+
? $data['version']
86+
: null;
87+
}
88+
}

framework/core/src/Foundation/Console/AssetsPublishCommand.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111

1212
use Flarum\Console\AbstractCommand;
1313
use Flarum\Extension\ExtensionManager;
14+
use Flarum\Formatter\XsltPolyfill;
1415
use Flarum\Foundation\Paths;
1516
use Illuminate\Contracts\Container\Container;
17+
use Illuminate\Contracts\Filesystem\Cloud;
1618
use Illuminate\Filesystem\Filesystem;
1719

1820
class AssetsPublishCommand extends AbstractCommand
@@ -67,6 +69,8 @@ protected function fire()
6769
$target->put("fonts/$relPath", $local->get($fullPath));
6870
}
6971

72+
$this->publishXsltPolyfill($target, $local);
73+
7074
$this->info('Publishing extension assets...');
7175

7276
$extensions = $this->container->make(ExtensionManager::class);
@@ -79,4 +83,37 @@ protected function fire()
7983
}
8084
}
8185
}
86+
87+
/**
88+
* Copies the xslt-polyfill bundle into the public assets disk so the
89+
* Formatter can hand the browser a public URL when native XSLT is
90+
* disabled. Both files are kept in their original relative layout
91+
* (root + ./dist) so the polyfill's currentScript-based wasm loader
92+
* keeps working.
93+
*
94+
* @param Cloud $target
95+
* @param Filesystem $local
96+
*/
97+
private function publishXsltPolyfill(Cloud $target, Filesystem $local)
98+
{
99+
$sourceDir = XsltPolyfill::findSource();
100+
101+
if ($sourceDir === null) {
102+
$this->info('xslt-polyfill not found in node_modules; skipping.');
103+
104+
return;
105+
}
106+
107+
$files = [
108+
'xslt-polyfill.min.js' => 'xslt-polyfill/xslt-polyfill.min.js',
109+
'dist/xslt-wasm.js' => 'xslt-polyfill/dist/xslt-wasm.js',
110+
];
111+
112+
foreach ($files as $relSource => $relTarget) {
113+
$sourcePath = "$sourceDir/$relSource";
114+
if ($local->exists($sourcePath)) {
115+
$target->put($relTarget, $local->get($sourcePath));
116+
}
117+
}
118+
}
82119
}

framework/core/src/Frontend/Document.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
namespace Flarum\Frontend;
1111

12+
use Flarum\Formatter\XsltPolyfill;
1213
use Flarum\Frontend\Driver\TitleDriverInterface;
14+
use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory;
1315
use Illuminate\Contracts\Support\Renderable;
1416
use Illuminate\Contracts\View\Factory;
1517
use Illuminate\Contracts\View\View;
@@ -279,9 +281,50 @@ protected function makeHead(): string
279281
return '<meta name="'.e($name).'" content="'.e($content).'">';
280282
}, $this->meta, array_keys($this->meta)));
281283

284+
if ($polyfill = $this->makeXsltPolyfillLoader()) {
285+
$head[] = $polyfill;
286+
}
287+
282288
return implode("\n", array_merge($head, $this->head));
283289
}
284290

291+
/**
292+
* Emit a tiny inline detector that synchronously document.write()s a
293+
* <script src="…xslt-polyfill.min.js"> tag if the browser has no
294+
* working XSLTProcessor. Because document.write of a script tag during
295+
* HTML parsing inserts it inline, the parser blocks until the polyfill
296+
* loads and executes — this guarantees window.XSLTProcessor is in
297+
* place before forum.js runs (s9e calls `new XSLTProcessor` at
298+
* top-level module load).
299+
*
300+
* Browsers with native XSLT pay the cost of the detector only (~200
301+
* bytes); only affected browsers fetch the polyfill itself.
302+
*
303+
* @return string|null
304+
*/
305+
private function makeXsltPolyfillLoader()
306+
{
307+
// @todo v2.0 inject FilesystemFactory as dependency instead
308+
$url = XsltPolyfill::publicUrl(resolve(FilesystemFactory::class));
309+
if ($url === null) {
310+
return null;
311+
}
312+
313+
// JSON-encode the URL with HTML-safe flags so it can't break out of
314+
// the JS string context, even if a hostile asset URL contained
315+
// quotes / angle brackets / ampersands. The JSON-encoded value is
316+
// already a JS string literal (with surrounding quotes), so it can
317+
// be concatenated into the document.write() argument directly.
318+
$jsUrl = json_encode($url, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
319+
320+
// The closing </script> for the written-out tag is split across the
321+
// string literal so the *outer* <script> doesn't close early when
322+
// the HTML parser scans for </script>.
323+
return <<<HTML
324+
<script>(function(){try{if(typeof XSLTProcessor!=="undefined"&&new XSLTProcessor())return;}catch(e){}document.write('<script src='+$jsUrl+'><\/script>');})();</script>
325+
HTML;
326+
}
327+
285328
/**
286329
* @return string
287330
*/
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Tests\integration\console;
11+
12+
use Flarum\Testing\integration\ConsoleTestCase;
13+
use Illuminate\Contracts\Filesystem\Factory;
14+
use Illuminate\Contracts\Filesystem\Filesystem;
15+
16+
class AssetsPublishTest extends ConsoleTestCase
17+
{
18+
private function getAssetsDisk(): Filesystem
19+
{
20+
return $this->app()->getContainer()->make(Factory::class)->disk('flarum-assets');
21+
}
22+
23+
/**
24+
* @test
25+
*/
26+
public function publish_command_copies_xslt_polyfill()
27+
{
28+
$disk = $this->getAssetsDisk();
29+
$disk->delete('xslt-polyfill/xslt-polyfill.min.js');
30+
$disk->delete('xslt-polyfill/dist/xslt-wasm.js');
31+
32+
$this->runCommand(['command' => 'assets:publish']);
33+
34+
// The polyfill is vendored in framework/core/js/dist/xslt-polyfill/
35+
// and ships with flarum/core, so publish should always emit both
36+
// files into the assets disk preserving the dist/ layout.
37+
$this->assertTrue(
38+
$disk->exists('xslt-polyfill/xslt-polyfill.min.js'),
39+
'xslt-polyfill.min.js was not published into the flarum-assets disk.'
40+
);
41+
$this->assertTrue(
42+
$disk->exists('xslt-polyfill/dist/xslt-wasm.js'),
43+
'dist/xslt-wasm.js was not published into the flarum-assets disk.'
44+
);
45+
}
46+
47+
/**
48+
* @test
49+
*/
50+
public function published_polyfill_matches_source()
51+
{
52+
$disk = $this->getAssetsDisk();
53+
54+
$this->runCommand(['command' => 'assets:publish']);
55+
56+
$publishedSize = $disk->size('xslt-polyfill/xslt-polyfill.min.js');
57+
58+
$sourcePath = __DIR__.'/../../../js/dist/xslt-polyfill/xslt-polyfill.min.js';
59+
$this->assertFileExists($sourcePath);
60+
61+
$this->assertEquals(filesize($sourcePath), $publishedSize, 'Published polyfill size differs from source.');
62+
}
63+
}

0 commit comments

Comments
 (0)