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