Skip to content

Commit

Permalink
Merge pull request #4510 from iclanton/true-hash-plugin
Browse files Browse the repository at this point in the history
[webpack5-localization-plugin] Include a feature for generating true filename hashes.
  • Loading branch information
iclanton authored Feb 5, 2024
2 parents b7a8ce3 + 4bcf9e5 commit 14ccbe5
Show file tree
Hide file tree
Showing 33 changed files with 6,571 additions and 334 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@rushstack/node-core-library",
"comment": "Inclue a `Text.reverse` API for reversing a string.",
"type": "minor"
}
],
"packageName": "@rushstack/node-core-library"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@rushstack/webpack5-localization-plugin",
"comment": "Include an option called `realContentHash` that updates \"[contenthash]\" hashes to the actual hashes of chunks.",
"type": "minor"
}
],
"packageName": "@rushstack/webpack5-localization-plugin"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@rushstack/webpack5-localization-plugin",
"comment": "Add a warning if `optimization.realContentHash` is set.",
"type": "minor"
}
],
"packageName": "@rushstack/webpack5-localization-plugin"
}
1 change: 1 addition & 0 deletions common/reviews/api/node-core-library.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,7 @@ export class Text {
static readLinesFromIterable(iterable: Iterable<string | Buffer | null>, options?: IReadLinesFromIterableOptions): Generator<string>;
static readLinesFromIterableAsync(iterable: AsyncIterable<string | Buffer>, options?: IReadLinesFromIterableOptions): AsyncGenerator<string>;
static replaceAll(input: string, searchValue: string, replaceValue: string): string;
static reverse(s: string): string;
static truncateWithEllipsis(s: string, maximumLength: number): string;
}

Expand Down
3 changes: 3 additions & 0 deletions common/reviews/api/webpack5-localization-plugin.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface ILocalizationPluginOptions {
localizationStats?: ILocalizationStatsOptions;
localizedData: ILocalizedData;
noStringsLocaleName?: string;
realContentHash?: boolean;
runtimeLocaleExpression?: string;
}

Expand Down Expand Up @@ -135,6 +136,8 @@ export class LocalizationPlugin implements WebpackPluginInstance {
getDataForSerialNumber(serialNumber: string): _IStringPlaceholder | undefined;
// (undocumented)
getPlaceholder(localizedFileKey: string, stringName: string): _IStringPlaceholder | undefined;
// @internal (undocumented)
readonly _options: ILocalizationPluginOptions;
// (undocumented)
readonly stringKeys: Map<string, _IStringPlaceholder>;
}
Expand Down
8 changes: 8 additions & 0 deletions libraries/node-core-library/src/Text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,4 +271,12 @@ export class Text {
yield remaining;
}
}

/**
* Returns a new string that is the input string with the order of characters reversed.
*/
public static reverse(s: string): string {
// Benchmarks of several algorithms: https://jsbench.me/4bkfflcm2z
return s.split('').reduce((newString, char) => char + newString, '');
}
}
36 changes: 22 additions & 14 deletions webpack/webpack5-localization-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,21 @@ any translations.

## Options

### `localizedData = { }`
### `localizedData: { }`

#### `localizedData.defaultLocale = { }`
#### `localizedData.defaultLocale: { }`

This option has a required property (`localeName`), to specify the name of the locale used in the
`.resx` and `.loc.json` files in the source.

##### `localizedData.defaultLocale.fillMissingTranslationStrings = true | false`
##### `localizedData.defaultLocale.fillMissingTranslationStrings: true | false`

If this option is set to `true`, strings that are missing from `localizedData.translatedStrings` will be
provided by the default locale (the strings in the `.resx` and `.loc.json` files in the source). If
this option is unset or set to `false`, an error will be emitted if a string is missing from
`localizedData.translatedStrings`.

#### `localizedData.translatedStrings = { }`
#### `localizedData.translatedStrings: { }`

This option is used to specify the localization data to be used in the build. This object has the following
structure:
Expand Down Expand Up @@ -101,7 +101,7 @@ translatedStrings: {
}
```

#### `localizedData.resolveMissingTranslatedStrings = (locales: string[], filePath: string, context: LoaderContext<{}>) => { ... }`
#### `localizedData.resolveMissingTranslatedStrings: (locales: string[], filePath: string, context: LoaderContext<{}>) => { ... }`

This optional option can be used to resolve translated data that is missing from data that is provided
in the `localizedData.translatedStrings` option. Set this option with a function expecting two parameters:
Expand All @@ -120,42 +120,42 @@ If the function returns data that is missing locales or individual strings, the
default locale if `localizedData.defaultLocale.fillMissingTranslationStrings` is set to `true`. If
`localizedData.defaultLocale.fillMissingTranslationStrings` is set to `false`, an error will result.

#### `localizedData.passthroughLocale = { }`
#### `localizedData.passthroughLocale: { }`

This option is used to specify how and if a passthrough locale should be generated. A passthrough locale
is a generated locale in which each string's value is its name. This is useful for debugging and for identifying
cases where a locale is missing.

This option takes two optional properties:

##### `localizedData.passthroughLocale.usePassthroughLocale = true | false`
##### `localizedData.passthroughLocale.usePassthroughLocale: true | false`

If `passthroughLocale.usePassthroughLocale` is set to `true`, a passthrough locale will be included in the output.
By default, the passthrough locale's name is "passthrough."

##### `localizedData.passthroughLocale.passthroughLocaleName = '...'`
##### `localizedData.passthroughLocale.passthroughLocaleName: '...'`

If `passthroughLocale.usePassthroughLocale` is set to `true`, the "passthrough" locale name can be overridden
by setting a value on `passthroughLocale.passthroughLocaleName`.

#### `localizedData.pseudolocales = { }`
#### `localizedData.pseudolocales: { }`

This option allows pseudolocales to be generated from the strings in the default locale. This option takes
an option with pseudolocales as keys and options for the
[pseudolocale package](https://www.npmjs.com/package/pseudolocale) as values.

### `noStringsLocaleName = '...'`
### `noStringsLocaleName: '...'`

The value to replace the `[locale]` token with for chunks without localized strings. Defaults to "none"

### `runtimeLocaleExpression = '...'`
### `runtimeLocaleExpression: '...'`

A chunk of raw ECMAScript to inject into the webpack runtime to resolve the current locale at execution time. Allows
multiple locales to share the same runtime chunk if it does not directly contain localized strings.

### `localizationStats = { }`
### `localizationStats: { }`

#### `localizationStats.dropPath = '...'`
#### `localizationStats.dropPath: '...'`

This option is used to designate a path at which a JSON file describing the localized assets produced should be
written. If this property is omitted, the stats file won't be written.
Expand Down Expand Up @@ -196,11 +196,19 @@ The file has the following format:

```

#### `localizationStats.callback = (stats) => { ... }`
#### `localizationStats.callback: (stats) => { ... }`

This option is used to specify a callback to be called with the stats data that would be dropped at
[`localizationStats.dropPath`](#localizationStats.DropPath--) after compilation completes.

### `realContentHash: true | false`

If this option is set to `true`, the plugin will update `[contenthash]` tokens in the output filenames to
use the true hash of the content, rather than an intermediate hash that is shared between all locales.

Note that this option is not compatible with the `runtimeLocaleExpression` option and will cause an error if
both are set.

## Links

- [CHANGELOG.md](https:/microsoft/rushstack/blob/main/webpack/localization-plugin/CHANGELOG.md) - Find
Expand Down
56 changes: 45 additions & 11 deletions webpack/webpack5-localization-plugin/src/LocalizationPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
import type { IAssetPathOptions } from './webpackInterfaces';
import { markEntity, getMark } from './utilities/EntityMarker';
import { processLocalizedAsset, processNonLocalizedAsset } from './AssetProcessor';
import { getHashFunction, type HashFn, updateAssetHashes } from './trueHashes';

/**
* @public
Expand Down Expand Up @@ -80,7 +81,10 @@ export function getPluginInstance(compiler: Compiler | undefined): LocalizationP
export class LocalizationPlugin implements WebpackPluginInstance {
public readonly stringKeys: Map<string, IStringPlaceholder> = new Map();

private readonly _options: ILocalizationPluginOptions;
/**
* @internal
*/
public readonly _options: ILocalizationPluginOptions;
private readonly _resolvedTranslatedStringsFromOptions: Map<
string,
Map<string, ILocaleFileObject | string | ReadonlyMap<string, string>>
Expand Down Expand Up @@ -129,10 +133,11 @@ export class LocalizationPlugin implements WebpackPluginInstance {
}
}

const { webpack: thisWebpack } = compiler;
const {
WebpackError,
runtime: { GetChunkFilenameRuntimeModule }
} = compiler.webpack;
} = thisWebpack;

// Side-channel for async chunk URL generator chunk, since the actual chunk is completely inaccessible
// from the assetPath hook below when invoked to build the async URL generator
Expand All @@ -157,6 +162,27 @@ export class LocalizationPlugin implements WebpackPluginInstance {
const { runtimeLocaleExpression } = this._options;

compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation: Compilation) => {
let hashFn: HashFn | undefined;
if (this._options.realContentHash) {
if (runtimeLocaleExpression) {
compilation.errors.push(
new WebpackError(
`The "realContentHash" option cannot be used in conjunction with "runtimeLocaleExpression".`
)
);
} else {
hashFn = getHashFunction({ thisWebpack, compilation });
}
} else if (compiler.options.optimization?.realContentHash) {
compilation.errors.push(
new thisWebpack.WebpackError(
`The \`optimization.realContentHash\` option is set and the ${LocalizationPlugin.name}'s ` +
'`realContentHash` option is not set. This will likely produce invalid results. Consider setting the ' +
`\`realContentHash\` option in the ${LocalizationPlugin.name} plugin.`
)
);
}

compilation.hooks.assetPath.tap(
PLUGIN_NAME,
(assetPath: string, options: IAssetPathOptions): string => {
Expand Down Expand Up @@ -274,13 +300,14 @@ export class LocalizationPlugin implements WebpackPluginInstance {
const locales: Set<string> = new Set(this._resolvedLocalizedStrings.keys());

const { chunkGraph, chunks } = compilation;
const { localizationStats: statsOptions } = this._options;

const filesByChunkName: Map<string, Record<string, string>> = new Map();
const filesByChunkName: Map<string, Record<string, string>> | undefined = statsOptions
? new Map()
: undefined;
const localizedEntryPointNames: string[] = [];
const localizedChunkNames: string[] = [];

const { localizationStats: statsOptions } = this._options;

for (const chunk of chunks) {
const isLocalized: boolean = _chunkHasLocalizedModules(
chunkGraph,
Expand Down Expand Up @@ -319,11 +346,9 @@ export class LocalizationPlugin implements WebpackPluginInstance {
filenameTemplate: template
});

if (statsOptions) {
if (chunk.name) {
filesByChunkName.set(chunk.name, localizedAssets);
(chunk.hasRuntime() ? localizedEntryPointNames : localizedChunkNames).push(chunk.name);
}
if (filesByChunkName && chunk.name) {
filesByChunkName.set(chunk.name, localizedAssets);
(chunk.hasRuntime() ? localizedEntryPointNames : localizedChunkNames).push(chunk.name);
}
} else {
processNonLocalizedAsset({
Expand All @@ -340,8 +365,17 @@ export class LocalizationPlugin implements WebpackPluginInstance {
}
}

if (hashFn) {
updateAssetHashes({
thisWebpack,
compilation,
hashFn,
filesByChunkName
});
}

// Since the stats generation doesn't depend on content, do it immediately
if (statsOptions) {
if (statsOptions && filesByChunkName) {
const localizationStats: ILocalizationStats = {
entrypoints: {},
namedChunkGroups: {}
Expand Down
2 changes: 1 addition & 1 deletion webpack/webpack5-localization-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

export { LocalizationPlugin, IStringPlaceholder as _IStringPlaceholder } from './LocalizationPlugin';
export { LocalizationPlugin, type IStringPlaceholder as _IStringPlaceholder } from './LocalizationPlugin';

export {
IDefaultLocaleOptions,
Expand Down
5 changes: 5 additions & 0 deletions webpack/webpack5-localization-plugin/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ export interface ILocalizationPluginOptions {
* runtimeLocaleExpression produces the same output as formatLocaleForFilename.
*/
formatLocaleForFilename?: (locale: string) => string;

/**
* If set to true, update usages of [contenthash] to use the true hash of the file contents
*/
realContentHash?: boolean;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ async function testLocalizedAsyncDynamicInner(minimize: boolean): Promise<void>
memoryFileSystem.fromJSON(
{
'/a/package.json': '{ "name": "a", "sideEffects": ["entry.js", "async.js"] }',
'/a/async.js': `import strings1 from './strings1.resjson'; import strings2 from './strings2.resjson'; console.log(strings1.test, strings2.another);`,
'/a/entry.js': `import(/* webpackChunkName: 'async' */ './async');`,
'/a/async1.js': `import strings1 from './strings1.resjson'; import strings2 from './strings2.resjson'; console.log(strings1.test, strings2.another);`,
'/a/async2.js': `import strings1 from './strings1.resjson'; import strings2 from './strings2.resjson'; console.log(strings1.test + strings2.another);`,
'/a/entrySingleChunk.js': `import(/* webpackChunkName: 'async1' */ './async1');`,
'/a/entryTwoChunks.js': `import(/* webpackChunkName: 'async1' */ './async1');import(/* webpackChunkName: 'async2' */ './async2');`,
'/a/strings1.resjson': `{"test":"blah","_test.comment":"A string"}`,
'/a/strings2.resjson': `{"another":"something else","_another.comment":"Another string"}`
},
Expand All @@ -34,10 +36,10 @@ async function testLocalizedAsyncDynamicInner(minimize: boolean): Promise<void>
const options: ILocalizationPluginOptions = {
localizedData: {
defaultLocale: {
localeName: 'en-us'
localeName: 'LOCALE1'
},
translatedStrings: {
foo: {
LOCALE2: {
'/a/strings1.resjson': {
test: 'baz'
},
Expand All @@ -57,12 +59,13 @@ async function testLocalizedAsyncDynamicInner(minimize: boolean): Promise<void>

const compiler: Compiler = webpack({
entry: {
main: '/a/entry.js'
mainSingleChunk: '/a/entrySingleChunk.js',
mainTwoChunks: '/a/entryTwoChunks.js'
},
output: {
path: '/release',
filename: '[name]-[locale].js',
chunkFilename: 'chunks/[name]-[locale].js'
filename: '[name]-[locale]-[contenthash].js',
chunkFilename: 'chunks/[name]-[locale]-[contenthash].js'
},
module: {
rules: [
Expand All @@ -78,7 +81,8 @@ async function testLocalizedAsyncDynamicInner(minimize: boolean): Promise<void>
},
optimization: {
minimize,
moduleIds: 'named'
moduleIds: 'named',
realContentHash: false
},
context: '/',
mode: 'production',
Expand All @@ -98,6 +102,9 @@ async function testLocalizedAsyncDynamicInner(minimize: boolean): Promise<void>
expect(results).toMatchSnapshot('Content');

expect(localizationStats).toMatchSnapshot('Localization Stats');

expect(errors).toHaveLength(0);
expect(warnings).toHaveLength(0);
}

describe(LocalizationPlugin.name, () => {
Expand Down
Loading

0 comments on commit 14ccbe5

Please sign in to comment.