Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ const chunkNameMarker = '__VAADIN_I18n_chunkName__';
const registerChunkImport = `import { i18n } from '@vaadin/hilla-react-i18n';\n`;
const registerChunkCall = `await i18n.registerChunk('${chunkNameMarker}');\n`;

// Matches a registerChunk call with the chunk name marker in the rendered
// chunk, where the i18n binding may have been renamed by the bundler and the
// string quotes may have been rewritten.
const registerChunkCallPattern = new RegExp(
`(?:await\\s*)?[\\w$]+\\.registerChunk\\(\\s*(['"\`])${chunkNameMarker}\\1\\s*\\)\\s*;?`,
'g'
);

/**
* Vaadin Rollup/Vite plugin for automatic splitting of i18n bundles for Hilla
* apps based on the chunks in the JS bundle output.
Expand Down Expand Up @@ -125,34 +133,42 @@ export default function vaadinI18n(options = {}) {
renderStart() {
chunkKeySets.clear();
},
renderChunk(code, chunk) {
const magicString = new MagicString(code);
// Extra imports are removed automatically from the final chunk, but
// there might be still multiple registerChunk calls originating from
// the modules using Hilla i18n. One such call per chunk is enough
// to load all the translations for the code below it, so let us keep
// the first one, with the marker replaced by the actual chunk name,
// and remove the rest.
let isFirstCall = true;
for (const match of code.matchAll(registerChunkCallPattern)) {
const start = match.index;
const end = start + match[0].length;
if (isFirstCall) {
magicString.overwrite(start, end, match[0].replace(chunkNameMarker, chunk.fileName));
isFirstCall = false;
} else {
magicString.remove(start, end);
}
}

if (!magicString.hasChanged()) {
// Nothing to rewrite in this chunk: leave it and its sourcemap as is
return null;
}

// The rewriting happens in renderChunk, not in generateBundle, so that
// the bundler composes this sourcemap with the one it has for the chunk
// instead of the chunk ending up with a sourcemap without sources.
return {
code: magicString.toString(),
map: magicString.generateMap({ hires: true })
};
},
async generateBundle(_options, bundle) {
for (const [fileName, chunk] of Object.entries(bundle)) {
if (chunk.type === 'chunk') {
const magicString = new MagicString(chunk.code);
// Extra imports are removed automatically from the final chunk, but
// there might be still multiple registerChunk calls originating from
// the modules using Hilla i18n. One such call per chunk is enough
// to load all the translations for the code below it, so let us
// remove the duplicate calls.
let idx = 0;
let firstIdx = -1;
let searchIdx = 0;
while ((idx = magicString.toString().indexOf(registerChunkCall, searchIdx)) !== -1) {
if (firstIdx === -1) {
firstIdx = idx;
searchIdx = idx + registerChunkCall.length;
} else {
// Remove this occurrence
magicString.remove(idx, idx + registerChunkCall.length);
searchIdx = idx; // Don't advance, as string just got shorter
}
}

// Replace the chunk name markers with the actual chunk name
magicString.replace(chunkNameMarker, fileName);
chunk.code = magicString.toString();
chunk.map = magicString.generateMap({ hires: true });

// Collect i18n translation keys from all modules of the chunk
const chunkKeySet = new Set();
for (const id of chunk.moduleIds) {
Expand Down
1 change: 1 addition & 0 deletions flow-tests/test-frontend/vite-production/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"@polymer/polymer": "3.5.2",
"@vaadin/bundles": "../vite-test-assets/packages/@vaadin/bundles",
"@vaadin/common-frontend": "0.0.23",
"@vaadin/hilla-react-i18n": "../vite-test-assets/packages/@vaadin/hilla-react-i18n",
"@vaadin/test-package-outside-npm": "file:../vite-test-assets/packages/@vaadin/test-package-outside-npm",
"@vaadin/test-package2-outside-npm": "../vite-test-assets/packages/@vaadin/test-package2-outside-npm",
"@vaadin/testscope-all": "../vite-test-assets/packages/@vaadin/testscope-all",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// A second module with translations in the same chunk, so that the build
// plugin has more than one registerChunk call to clean up.
import { key, translate } from '@vaadin/hilla-react-i18n';

window.i18nChunkExtraTranslation = translate(key`i18n.chunk.test.extra`);
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Uses Hilla i18n translations so that the Vaadin i18n build plugin rewrites
// the chunk this module ends up in, adding the registerChunk call for it.
import { key, translate } from '@vaadin/hilla-react-i18n';

window.i18nChunkTranslation = translate(key`i18n.chunk.test`);
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
@JsModule("@vaadin/test-package-outside-npm/index.js")
@JsModule("@vaadin/test-package2-outside-npm/index.js")
@JsModule("./toplevelawait-main.js")
@JsModule("./i18n-chunk.js")
@JsModule("./i18n-chunk-extra.js")
@CssImport("./image.css")
@StyleSheet("styles/static-stylesheet.css")
public class MainView extends Div {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright 2000-2026 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.viteapp;

import java.io.FileNotFoundException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.io.IOUtils;

/**
* Reads the files of the production bundle from the running application, for
* the tests that assert on what the frontend build produced.
*/
public class BundleAccess {

protected static final String BUILD_PATH = "/VAADIN/build/";

private static final Pattern ENTRY_BUNDLE = Pattern
.compile(".* src=\"\\./VAADIN/build/([^\"]*)\".*", Pattern.DOTALL);

protected String getRootURL() {
return "http://localhost:8888";
}

/**
* Returns the name of the bundle that index.html loads.
*/
protected String getJsBundleName() throws Exception {
Matcher matcher = ENTRY_BUNDLE.matcher(download("/index.html"));
if (!matcher.matches()) {
throw new IllegalStateException("No script found");
}
return matcher.group(1);
}

protected String download(String path) throws Exception {
return IOUtils.toString(new URL(getRootURL() + path),
StandardCharsets.UTF_8);
}

/**
* Downloads the given path, or returns {@code null} when the application
* does not serve it.
*/
protected String downloadIfAvailable(String path) throws Exception {
try {
return download(path);
} catch (FileNotFoundException e) {
return null;
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright 2000-2026 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.viteapp;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.junit.Assert;
import org.junit.Test;

/**
* Tests the chunk rewriting of the Vaadin i18n build plugin, which the
* application triggers with the Hilla translations in {@code i18n-chunk.js}.
*/
public class I18nChunkIT extends BundleAccess {

private static final String CHUNK_NAME_MARKER = "__VAADIN_I18n_chunkName__";

private static final Pattern REGISTER_CHUNK = Pattern
.compile("registerChunk\\(\\s*[\"'`]([^\"'`]+)[\"'`]\\s*\\)");

@Test
public void chunkRegistersItselfUnderItsOwnName() throws Exception {
String bundle = getBundleWithTranslations();
Matcher matcher = REGISTER_CHUNK.matcher(download(BUILD_PATH + bundle));

Assert.assertTrue(bundle + " should register itself for translations",
matcher.find());
Assert.assertEquals(
"The chunk should be registered under the name it is served "
+ "with, which is the name the i18n metadata uses",
BUILD_PATH.substring(1) + bundle, matcher.group(1));
Assert.assertFalse(
bundle + " should not have more than one registerChunk call",
matcher.find());
}

/**
* Finds the bundle that has the translations of the application, by the
* marker the i18n plugin replaces with the name of the chunk.
*/
private String getBundleWithTranslations() throws Exception {
String entryBundle = getJsBundleName();
Matcher matcher = Pattern.compile("[\"']\\./([^\"']+\\.js)[\"']")
.matcher(download(BUILD_PATH + entryBundle));
while (matcher.find()) {
String bundle = matcher.group(1);
String contents = downloadIfAvailable(BUILD_PATH + bundle);
if (contents != null && contents.contains("i18n.chunk.test")) {
Assert.assertFalse(
bundle + " should have the chunk name marker replaced",
contents.contains(CHUNK_NAME_MARKER));
return bundle;
}
}
throw new IllegalStateException(
"No bundle with the translations of the application found");
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* Copyright 2000-2026 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.viteapp;

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.junit.Assert;
import org.junit.Test;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;

public class SourceMapsIT extends BundleAccess {

private static final Pattern IMPORTED_BUNDLE = Pattern
.compile("[\"']\\./([^\"']+\\.js)[\"']");
private static final Pattern SOURCE_MAPPING_URL = Pattern
.compile("//# sourceMappingURL=(\\S+)");

/**
* A build plugin that rewrites a chunk must chain its sourcemap onto the
* one the bundler already has for that chunk, otherwise the emitted .map
* file ends up without the original sources and the browser cannot map the
* bundle back to them.
*/
@Test
public void bundleSourceMapsPointToOriginalSources() throws Exception {
String entryBundle = getJsBundleName();
int checkedBundles = 0;

for (String bundle : getBundles(entryBundle)) {
String contents = download(BUILD_PATH + bundle);
Matcher matcher = SOURCE_MAPPING_URL.matcher(contents);
if (!matcher.find()) {
Assert.assertNotEquals(
entryBundle + " should refer to an emitted sourcemap",
entryBundle, bundle);
// Bundler runtime helpers are emitted without a sourcemap
continue;
}
assertSourceMapUsable(bundle, JsonMapper.shared()
.readTree(download(BUILD_PATH + matcher.group(1))));
checkedBundles++;
}

Assert.assertNotEquals("No bundle with a sourcemap was found", 0,
checkedBundles);
}

private void assertSourceMapUsable(String bundle, JsonNode sourceMap) {
JsonNode sources = sourceMap.get("sources");
JsonNode sourcesContent = sourceMap.get("sourcesContent");
Assert.assertNotEquals(bundle + " should have a sourcemap with sources",
0, sources.size());
Assert.assertNotEquals(
bundle + " should have a sourcemap with mappings", "",
sourceMap.get("mappings").asString());
Assert.assertEquals(
bundle + " should have the contents of every source in its "
+ "sourcemap",
sources.size(), sourcesContent.size());
for (int i = 0; i < sources.size(); i++) {
Assert.assertNotEquals(
bundle + " should have a sourcemap referring to the "
+ "original files, was " + sources.get(i),
"", sources.get(i).asString().trim());
Assert.assertNotEquals(
bundle + " should have the contents of "
+ sources.get(i).asString() + " in its sourcemap",
"", sourcesContent.get(i).asString().trim());
}
}

/**
* Collects the given bundle and the bundles it imports, directly or through
* another bundle. Names that are not served are left out, as not every file
* name in a bundle is an emitted chunk.
*/
private Set<String> getBundles(String entryBundle) throws Exception {
Set<String> bundles = new LinkedHashSet<>();
Deque<String> pending = new ArrayDeque<>();
pending.add(entryBundle);
while (!pending.isEmpty()) {
String bundle = pending.remove();
String contents = downloadIfAvailable(BUILD_PATH + bundle);
if (contents == null || !bundles.add(bundle)) {
continue;
}
Matcher matcher = IMPORTED_BUNDLE.matcher(contents);
while (matcher.find()) {
pending.add(matcher.group(1));
}
}
return bundles;
}

}
5 changes: 5 additions & 0 deletions flow-tests/test-frontend/vite-production/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { overrideVaadinConfig } from './vite.generated.ts';
const customConfig: UserConfigFn = (env) => ({
// Here you can add custom Vite parameters
// https://vitejs.dev/config/
build: {
// Emit separate .map files for the production bundle so that
// SourceMapsIT can verify that the build plugins keep them usable
sourcemap: true
}
});

export default overrideVaadinConfig(customConfig);
Loading
Loading