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
16 changes: 10 additions & 6 deletions ghost/core/core/frontend/services/theme-engine/i18n/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -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 },
);
}

/**
Expand Down Expand Up @@ -86,6 +93,7 @@ class I18n {
* - Load proper language file into memory
*/
init() {
this._compileMessage.reset();
this._strings = this._loadStrings();
}

Expand Down Expand Up @@ -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) {
Expand Down
62 changes: 62 additions & 0 deletions ghost/core/core/shared/memoize.ts
Original file line number Diff line number Diff line change
@@ -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<A extends unknown[], R> = ((...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<A extends unknown[], R>(
compute: (...args: A) => R,
key: (...args: A) => string,
{ max }: Readonly<MemoizeOptions>,
): Memoized<A, R> {
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<string, R & {}>({ 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<A, R>;

memoized.reset = () => cache.clear();

return memoized;
}
1 change: 1 addition & 0 deletions ghost/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof I18n>;
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);
});
});
158 changes: 158 additions & 0 deletions ghost/core/test/unit/shared/memoize.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading