Skip to content

Commit 5693358

Browse files
Merge pull request #60659 from nextcloud/backport/56941/stable32
[stable32] fix(template): add import map for JS module entry points
2 parents 5cedbbe + eaa634c commit 5693358

2 files changed

Lines changed: 55 additions & 10 deletions

File tree

lib/private/Template/functions.php

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,23 +54,22 @@ function emit_css_loading_tags($obj): void {
5454
* @param string $src the source URL, ignored when empty
5555
* @param string $script_content the inline script content, ignored when empty
5656
* @param string $content_type the type of the source (e.g. 'module')
57+
*
58+
* @since 27.0.0 added the $content_type parameter
5759
*/
5860
function emit_script_tag(string $src, string $script_content = '', string $content_type = ''): void {
5961
$nonceManager = Server::get(ContentSecurityPolicyNonceManager::class);
6062

61-
$defer_str = ' defer';
63+
$defer_str = $content_type === '' ? ' defer' : ''; // "defer" only works with classic scripts
6264
$type = $content_type !== '' ? ' type="' . $content_type . '"' : '';
6365

64-
$s = '<script nonce="' . $nonceManager->getNonce() . '"';
66+
$s = '<script nonce="' . $nonceManager->getNonce() . '"' . $type;
6567
if (!empty($src)) {
6668
// emit script tag for deferred loading from $src
67-
$s .= $defer_str . ' src="' . $src . '"' . $type . '>';
68-
} elseif ($script_content !== '') {
69+
$s .= $defer_str . ' src="' . $src . '">';
70+
} else {
6971
// emit script tag for inline script from $script_content without defer (see MDN)
7072
$s .= ">\n" . $script_content . "\n";
71-
} else {
72-
// no $src nor $src_content, really useless empty tag
73-
$s .= '>';
7473
}
7574
$s .= '</script>';
7675
print_unescaped($s . "\n");
@@ -81,6 +80,8 @@ function emit_script_tag(string $src, string $script_content = '', string $conte
8180
* @param array $obj all the script information from template
8281
*/
8382
function emit_script_loading_tags($obj): void {
83+
emit_import_map($obj);
84+
8485
foreach ($obj['jsfiles'] as $jsfile) {
8586
$fileName = explode('?', $jsfile, 2)[0];
8687
$type = str_ends_with($fileName, '.mjs') ? 'module' : '';
@@ -91,6 +92,29 @@ function emit_script_loading_tags($obj): void {
9192
}
9293
}
9394

95+
/**
96+
* Print the import map for the current JS modules.
97+
* The import map is needed to ensure that an import of an entry point does not duplicate the state,
98+
* but reuses the already loaded module. This is needed because Nextcloud will append a cache buster
99+
* to the entry point URLs but the scripts does not know about that (both must match).
100+
*
101+
* @param $obj all the script information from template
102+
*/
103+
function emit_import_map(array $obj): void {
104+
$modules = [];
105+
foreach ($obj['jsfiles'] as $jsfile) {
106+
$fileName = explode('?', $jsfile, 2)[0];
107+
if (str_ends_with($fileName, '.mjs') && $jsfile !== $fileName) {
108+
// its a module and we have a cache buster available
109+
$modules[$fileName] = $jsfile;
110+
}
111+
}
112+
if (!empty($modules)) {
113+
$json = json_encode(['imports' => $modules], JSON_UNESCAPED_SLASHES | JSON_FORCE_OBJECT);
114+
emit_script_tag('', $json, 'importmap');
115+
}
116+
}
117+
94118
/**
95119
* Prints an unsanitized string - usage of this function may result into XSS.
96120
* Consider using p() instead.

tests/lib/TemplateFunctionsTest.php

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,34 @@ public function testEmitScriptTagWithSource(): void {
5454
}
5555

5656
public function testEmitScriptTagWithModuleSource(): void {
57-
$this->expectOutputRegex('/<script nonce=".*" defer src="some.mjs" type="module"><\/script>/');
57+
$this->expectOutputRegex('/<script nonce=".*" type="module" src="some.mjs"><\/script>/');
5858
emit_script_tag('some.mjs', '', 'module');
5959
}
6060

61+
public function testEmitImportMap(): void {
62+
$this->expectOutputRegex('/^<script[^>]+type="importmap">\n{"imports":{"\/some\/path\/file\.mjs":"\/some\/path\/file\.mjs\?v=123"}}\n<\/script>$/m');
63+
emit_import_map(['jsfiles' => ['/some/path/file.mjs?v=123']]);
64+
}
65+
66+
// only create import map for modules with versioning
67+
public function testEmitImportMapMixedScripts(): void {
68+
$this->expectOutputRegex('/^<script[^>]+type="importmap">\n{"imports":{"\/some\/path\/module\.mjs":"\/some\/path\/module\.mjs\?v=123"}}\n<\/script>$/m');
69+
emit_import_map(['jsfiles' => ['/some/path/module.mjs?v=123', '/some/path/classic.js?v=123']]);
70+
}
71+
72+
public function testEmitImportMapNoOutputWithoutVersion(): void {
73+
$this->expectOutputString('');
74+
emit_import_map(['jsfiles' => ['some.mjs']]);
75+
}
76+
77+
public function testEmitImportMapNoOutputWithClassicScript(): void {
78+
$this->expectOutputString('');
79+
emit_import_map(['jsfiles' => ['some.js?v=123']]);
80+
}
81+
6182
public function testEmitScriptLoadingTags(): void {
6283
// Test mjs js and inline content
63-
$pattern = '/src="some\.mjs"[^>]+type="module"[^>]*>.+\n'; // some.mjs with type = module
84+
$pattern = '/type="module"[^>]+src="some\.mjs"[^>]*>.+\n'; // some.mjs with type = module
6485
$pattern .= '<script[^>]+src="other\.js"[^>]*>.+\n'; // other.js as plain javascript
6586
$pattern .= '<script[^>]*>\n?.*inline.*\n?<\/script>'; // inline content
6687
$pattern .= '/'; // no flags
@@ -74,7 +95,7 @@ public function testEmitScriptLoadingTags(): void {
7495

7596
public function testEmitScriptLoadingTagsWithVersion(): void {
7697
// Test mjs js and inline content
77-
$pattern = '/src="some\.mjs\?v=ab123cd"[^>]+type="module"[^>]*>.+\n'; // some.mjs with type = module
98+
$pattern = '/type="module"[^>]+src="some\.mjs\?v=ab123cd"[^>]*>.+\n'; // some.mjs with type = module
7899
$pattern .= '<script[^>]+src="other\.js\?v=12abc34"[^>]*>.+\n'; // other.js as plain javascript
79100
$pattern .= '/'; // no flags
80101

0 commit comments

Comments
 (0)