diff --git a/ghost/core/core/frontend/services/theme-engine/i18n/i18n.js b/ghost/core/core/frontend/services/theme-engine/i18n/i18n.js
index d7a0e88b965b..f3ac5a8037bd 100644
--- a/ghost/core/core/frontend/services/theme-engine/i18n/i18n.js
+++ b/ghost/core/core/frontend/services/theme-engine/i18n/i18n.js
@@ -9,6 +9,7 @@ const isEqual = require('lodash/isEqual');
const isNil = require('lodash/isNil');
const merge = require('lodash/merge');
const get = require('lodash/get');
+const { memoize } = require('../../../../shared/memoize');
class I18n {
/**
@@ -23,6 +24,12 @@ class I18n {
this._stringMode = options.stringMode || 'dot';
this._strings = null;
+ // Fulltext keys can contain arbitrary content, so the memo is bounded.
+ this._compileMessage = memoize(
+ (locale, string) => new MessageFormat(string, locale),
+ (locale, string) => JSON.stringify([locale, string]),
+ { max: 5000 },
+ );
}
/**
@@ -86,6 +93,7 @@ class I18n {
* - Load proper language file into memory
*/
init() {
+ this._compileMessage.reset();
this._strings = this._loadStrings();
}
@@ -219,19 +227,15 @@ class I18n {
*/
_formatMessage(string, bindings) {
const currentLocale = this.locale();
- let msg = new MessageFormat(string, currentLocale);
try {
- msg = msg.format(bindings);
+ return this._compileMessage(currentLocale, string).format(bindings);
} catch (err) {
this._handleFormatError(err);
// fallback
- msg = new MessageFormat(this._fallbackError(), currentLocale);
- msg = msg.format();
+ return new MessageFormat(this._fallbackError(), currentLocale).format();
}
-
- return msg;
}
_handleUninitialisedError(key) {
diff --git a/ghost/core/core/shared/memoize.ts b/ghost/core/core/shared/memoize.ts
new file mode 100644
index 000000000000..fac40c2ac38a
--- /dev/null
+++ b/ghost/core/core/shared/memoize.ts
@@ -0,0 +1,62 @@
+// Memoization helper for pure derivations of immutable inputs.
+//
+// This is a memo helper, not a cache. Everything that goes through it must be a
+// pure function of arguments that never change underneath us. Anything derived
+// from mutable state needs real invalidation, which this deliberately does not
+// have.
+
+import * as errors from '@tryghost/errors';
+import { LRUCache } from 'lru-cache';
+
+/** A keyed memoized function. `reset()` empties it. */
+export type Memoized = ((...args: A) => R) & {
+ reset(): void;
+};
+
+export interface MemoizeOptions {
+ /** Maximum number of entries to retain. Must be a positive integer. */
+ max: number;
+}
+
+/**
+ * Memoize `compute` against a string key derived from its arguments, bounded by
+ * `max` entries in LRU order.
+ *
+ * There is deliberately no TTL: entries are pure derivations of immutable
+ * inputs, so an entry is never stale, only evicted to stay inside the bound.
+ *
+ * A throwing `compute` is not memoized: the next call with the same key retries.
+ */
+export function memoize(
+ compute: (...args: A) => R,
+ key: (...args: A) => string,
+ { max }: Readonly,
+): Memoized {
+ if (!Number.isSafeInteger(max) || max < 1) {
+ throw new errors.IncorrectUsageError({
+ message: `memoize: max must be a positive integer, got ${String(max)}`,
+ });
+ }
+
+ // lru-cache cannot store `undefined` (it reads as a miss), so an `undefined`
+ // result is recomputed rather than served from the memo.
+ const cache = new LRUCache({ max });
+
+ const memoized = ((...args: A) => {
+ const cacheKey = key(...args);
+ const cached = cache.get(cacheKey);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ const value = compute(...args);
+ if (value !== undefined) {
+ cache.set(cacheKey, value as R & {});
+ }
+ return value;
+ }) as Memoized;
+
+ memoized.reset = () => cache.clear();
+
+ return memoized;
+}
diff --git a/ghost/core/package.json b/ghost/core/package.json
index 8e23d5dd1e9c..701452a68006 100644
--- a/ghost/core/package.json
+++ b/ghost/core/package.json
@@ -205,6 +205,7 @@
"knex-migrator": "catalog:",
"leaky-bucket": "2.2.0",
"lodash": "catalog:",
+ "lru-cache": "catalog:",
"luxon": "3.7.2",
"mailgun.js": "10.4.0",
"metascraper": "5.55.2",
diff --git a/ghost/core/test/unit/frontend/services/theme-engine/i18n/cache.test.ts b/ghost/core/test/unit/frontend/services/theme-engine/i18n/cache.test.ts
new file mode 100644
index 000000000000..0a259721ec8d
--- /dev/null
+++ b/ghost/core/test/unit/frontend/services/theme-engine/i18n/cache.test.ts
@@ -0,0 +1,81 @@
+import assert from 'node:assert/strict';
+import sinon from 'sinon';
+import MessageFormat from 'intl-messageformat';
+// @ts-expect-error This module lacks type definitions.
+import I18n from '../../../../../../core/frontend/services/theme-engine/i18n/i18n';
+
+describe('i18n compiled message cache', function () {
+ let i18n: InstanceType;
+ let parse: sinon.SinonSpy;
+
+ beforeEach(function () {
+ i18n = new I18n({ stringMode: 'fulltext' });
+ sinon.stub(i18n, '_loadStrings').returns({ greeting: 'Hello {name}' });
+ sinon.stub(i18n, '_handleFormatError');
+ i18n.init();
+
+ // Every compiled message is parsed exactly once, so this counts compiles.
+ parse = sinon.spy(MessageFormat, '__parse');
+ });
+
+ afterEach(function () {
+ sinon.restore();
+ });
+
+ it('reuses the compiled translation with fresh bindings', function () {
+ assert.equal(i18n.t('greeting', { name: 'Alice' }), 'Hello Alice');
+ assert.equal(i18n.t('greeting', { name: 'Bob' }), 'Hello Bob');
+ sinon.assert.calledOnce(parse);
+
+ i18n._strings.greeting = 'Welcome {name}';
+ assert.equal(i18n.t('greeting', { name: 'Bob' }), 'Welcome Bob');
+ sinon.assert.calledTwice(parse);
+ });
+
+ it('clears compiled messages on init', function () {
+ i18n.t('greeting', { name: 'Alice' });
+ sinon.assert.calledOnce(parse);
+
+ i18n.init();
+ assert.equal(i18n.t('greeting', { name: 'Bob' }), 'Hello Bob');
+ sinon.assert.calledTwice(parse);
+ });
+
+ it('uses the locale as well as the message content', function () {
+ assert.equal(i18n.t('{n, number}', { n: 1234.5 }), '1,234.5');
+ i18n._locale = 'de';
+ assert.equal(i18n.t('{n, number}', { n: 1234.5 }), '1.234,5');
+ sinon.assert.calledTwice(parse);
+ });
+
+ it('falls back for malformed messages without caching them', function () {
+ assert.equal(i18n.t('Hello {'), 'An error occurred');
+ assert.equal(i18n.t('Hello {'), 'An error occurred');
+ sinon.assert.calledTwice(i18n._handleFormatError);
+ // Both attempts reparse the malformed message; the fallback text is parsed too.
+ assert.equal(parse.withArgs('Hello {').callCount, 2);
+ });
+
+ it('falls back for missing bindings and keeps the compiled message', function () {
+ assert.equal(i18n.t('greeting', {}), 'An error occurred');
+ assert.equal(i18n.t('greeting', { name: 'Alice' }), 'Hello Alice');
+ assert.equal(i18n.t('greeting', {}), 'An error occurred');
+ sinon.assert.calledTwice(i18n._handleFormatError);
+ assert.equal(parse.withArgs('Hello {name}').callCount, 1);
+ });
+
+ it('bounds the cache when fulltext keys contain arbitrary content', function () {
+ for (let n = 0; n < 5000; n += 1) {
+ assert.equal(i18n.t(`Message ${n}`), `Message ${n}`);
+ }
+ assert.equal(parse.callCount, 5000);
+
+ // One more entry evicts the least recently used one, 'Message 0'.
+ assert.equal(i18n.t('One more message'), 'One more message');
+ assert.equal(i18n.t('Message 4999'), 'Message 4999');
+ assert.equal(parse.callCount, 5001);
+
+ assert.equal(i18n.t('Message 0'), 'Message 0');
+ assert.equal(parse.callCount, 5002);
+ });
+});
diff --git a/ghost/core/test/unit/shared/memoize.test.ts b/ghost/core/test/unit/shared/memoize.test.ts
new file mode 100644
index 000000000000..e43f627016ea
--- /dev/null
+++ b/ghost/core/test/unit/shared/memoize.test.ts
@@ -0,0 +1,158 @@
+import assert from 'node:assert/strict';
+import { expectTypeOf } from 'vitest';
+import { memoize } from '../../../core/shared/memoize';
+
+describe('memoize', function () {
+ it('memoizes per key', function () {
+ let calls = 0;
+ const upper = memoize(
+ (input: string) => {
+ calls += 1;
+ return input.toUpperCase();
+ },
+ (input) => input,
+ { max: 10 },
+ );
+
+ assert.equal(upper('a'), 'A');
+ assert.equal(upper('a'), 'A');
+ assert.equal(upper('b'), 'B');
+ assert.equal(upper('b'), 'B');
+ assert.equal(calls, 2);
+ });
+
+ it('keys on the derived key, not the raw arguments', function () {
+ let calls = 0;
+ const format = memoize(
+ (locale: string, options: { style: string }) => {
+ calls += 1;
+ return `${locale}/${options.style}`;
+ },
+ (locale, options) => `${locale}:${options.style}`,
+ { max: 10 },
+ );
+
+ assert.equal(format('en', { style: 'long' }), 'en/long');
+ // A structurally different object with the same key is a hit.
+ assert.equal(format('en', { style: 'long' }), 'en/long');
+ assert.equal(calls, 1);
+
+ assert.equal(format('en', { style: 'short' }), 'en/short');
+ assert.equal(calls, 2);
+ });
+
+ it('bounds entries and evicts in LRU order', function () {
+ const seen: string[] = [];
+ const compute = memoize(
+ (input: string) => {
+ seen.push(input);
+ return input;
+ },
+ (input) => input,
+ { max: 2 },
+ );
+
+ compute('a');
+ compute('b');
+
+ // Touch 'a' so 'b' becomes the least recently used entry.
+ compute('a');
+ compute('c');
+
+ assert.deepEqual(seen, ['a', 'b', 'c']);
+
+ // 'a' and 'c' are still held; 'b' was evicted and recomputes.
+ compute('a');
+ compute('c');
+ assert.deepEqual(seen, ['a', 'b', 'c']);
+
+ compute('b');
+ assert.deepEqual(seen, ['a', 'b', 'c', 'b']);
+ });
+
+ it('rejects a max that is not a positive safe integer', function () {
+ const compute = (n: number) => n;
+ const key = (n: number) => String(n);
+
+ assert.throws(() => memoize(compute, key, { max: Infinity }), /positive integer, got Infinity/);
+ assert.throws(() => memoize(compute, key, { max: Number.NaN }), /positive integer, got NaN/);
+ assert.throws(() => memoize(compute, key, { max: 0 }), /positive integer, got 0/);
+ assert.throws(() => memoize(compute, key, { max: -1 }), /positive integer, got -1/);
+ assert.throws(() => memoize(compute, key, { max: 1.5 }), /positive integer, got 1.5/);
+ assert.throws(
+ () => memoize(compute, key, { max: 2 ** 53 }),
+ /positive integer, got 9007199254740992/,
+ );
+ });
+
+ it('clears on reset', function () {
+ let calls = 0;
+ const compute = memoize(
+ (input: string) => {
+ calls += 1;
+ return input;
+ },
+ (input) => input,
+ { max: 10 },
+ );
+
+ compute('a');
+ compute('a');
+ assert.equal(calls, 1);
+
+ compute.reset();
+
+ compute('a');
+ assert.equal(calls, 2);
+ });
+
+ it('does not memoize a throwing compute', function () {
+ let calls = 0;
+ const compute = memoize(
+ (input: string) => {
+ calls += 1;
+ if (calls < 2) {
+ throw new Error('boom');
+ }
+ return input;
+ },
+ (input) => input,
+ { max: 10 },
+ );
+
+ assert.throws(() => compute('a'), /boom/);
+ assert.equal(compute('a'), 'a');
+ assert.equal(compute('a'), 'a');
+ assert.equal(calls, 2);
+ });
+
+ it('recomputes an undefined result rather than storing it', function () {
+ let calls = 0;
+ const compute = memoize(
+ (input: string): string | undefined => {
+ calls += 1;
+ return input === 'miss' ? undefined : input;
+ },
+ (input) => input,
+ { max: 10 },
+ );
+
+ assert.equal(compute('miss'), undefined);
+ assert.equal(compute('miss'), undefined);
+ assert.equal(calls, 2);
+ });
+
+ it('preserves the compute signature', function () {
+ const compute = memoize(
+ (locale: string, count: number) => ({ locale, count }),
+ (locale, count) => `${locale}:${count}`,
+ { max: 10 },
+ );
+
+ expectTypeOf(compute).parameters.toEqualTypeOf<[string, number]>();
+ expectTypeOf(compute).returns.toEqualTypeOf<{ locale: string; count: number }>();
+ expectTypeOf(compute.reset).toEqualTypeOf<() => void>();
+
+ assert.deepEqual(compute('en', 1), { locale: 'en', count: 1 });
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 110af8a74304..249e44e6606b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -569,6 +569,9 @@ catalogs:
lodash:
specifier: 4.18.1
version: 4.18.1
+ lru-cache:
+ specifier: 11.5.2
+ version: 11.5.2
lucide-react:
specifier: 1.23.0
version: 1.23.0
@@ -2840,6 +2843,9 @@ importers:
lodash:
specifier: 'catalog:'
version: 4.18.1
+ lru-cache:
+ specifier: 'catalog:'
+ version: 11.5.2
luxon:
specifier: 3.7.2
version: 3.7.2
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index d3b701d89423..5b8025543769 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -154,6 +154,7 @@ catalog:
knex: 2.4.2
knex-migrator: 6.2.1
lodash: 4.18.1
+ lru-cache: 11.5.2
luxon: 3.7.2
lucide-react: 1.23.0
microdiff: 1.5.0