Replace react-intl and remove React from the widget (#2176)

Second and final step of #2166. `react-intl` is replaced by
`app/common/intl.tsx`, a small i18n binding over Preact context, and with
`react-redux` already gone nothing holds the React compatibility alias.

React is now absent from the lockfile, the installed tree, the config and
the bundles: `react`, `react-dom`, `react-intl`, `@types/react`,
`@preact/compat`, `use-sync-external-store` and `intl-messageformat` are
all gone, along with the `paths` entries in `tsconfig.json` and three
babel-loader excludes. Runtime dependencies go from 15 to 10.

`preact/compat` goes too, which matters more than its 3.8 kB. Importing
it anywhere installs hooks on preact's shared `options` that remap
`onFocus`/`onBlur` to `focusin`/`focusout` for every element and make
`@testing-library/preact` rewrite `change` to `input`, the two bugs
behind #2166, still live until now. `Button` was wrapped in `forwardRef`
with no caller passing one, and `TextareaAutosize` now takes its ref as
an ordinary prop. The workaround in `sort-picker.spec.tsx` is gone with
them, since `fireEvent.change` reaches a `<select>` again.

Gzipped, against master: `remark.mjs` 76.17 kB to 56.47, `last-comments.mjs`
37.97 to 18.26, `deleteme.mjs` 14.51 to 8.42. The limits move with them and
keep more relative headroom than master shipped.

### The binding

`IntlProvider`, `useIntl`, `createIntl`, `defineMessages`,
`FormattedMessage` and `IntlShape`. 32 files change only their import.

The export names copy react-intl's deliberately: `formatjs extract` finds
messages by recognising `defineMessages`, `FormattedMessage` and
`intl.formatMessage` in the AST rather than by import source, so renaming
one silently empties the catalogue. `frontend/CLAUDE.md` records that,
along with the destructive part: `translation:generate` would then strip
the unextracted keys from all 24 catalogues and the check would pass.

A message the binding cannot parse falls back to the message in the
source: a broken, unhandled or nested tag, a brace that is not a
well-formed placeholder, and a placeholder naming a value the caller did
not supply. `mk.json` and `th.json` carried broken markup and rendered in
English; both are repaired, so a catalogue sweep over every locale can now
require well-formed markup with no exceptions listed.

`translation:check` gained the validation that would have caught them when
they were proposed: a translation's tags have to be well-formed pairs of the
names the English string uses, with no attributes, and its placeholders have
to be ones the English string provides. Leaving a tag or a placeholder out
stays allowed. Run against master's catalogues it reports both.

### enzyme

`@types/enzyme` was the last thing pulling `@types/react`, so React could
not leave while enzyme stayed. Its three test files move to
`@testing-library/preact`, which now has no rival: `@testing-library/preact-hooks`
had one import left and its own unmet peer warning. `intersection-observer`
was a runtime dependency nothing imported, and the `cheerio` override lost
its last dependent with enzyme.

Enzyme's `.find(X).prop()` threw unless exactly one node matched, so the
converted tests assert node counts explicitly to keep that.

### Verified

All 181 message ids formatted across all 24 catalogues through both real
react-intl and this binding: 4344 comparisons, no differences. From a wiped
`node_modules`: `pnpm install --frozen-lockfile`, `pnpm lint`,
`pnpm type-check`, `pnpm test` (392 tests, 42 suites), `pnpm build`,
`pnpm size-check`, `pnpm translation-check`.
This commit is contained in:
Dmitry Verkhoturov
2026-08-21 02:30:26 -05:00
committed by GitHub
parent a91e322d5c
commit fc4e10573c
59 changed files with 983 additions and 713 deletions
+53 -7
View File
@@ -1,6 +1,6 @@
# Frontend dependency updates
# Frontend gotchas
Non-obvious things that bit us during the pnpm 8→10 / node 16→20 migration (PR #2091, fixing #2085). Read before bumping dependencies or the node/pnpm toolchain again.
Non-obvious constraints in the frontend toolchain and widget. Read before bumping dependencies or the node/pnpm versions, and before changing how the widget renders or how translations are extracted.
## The node/pnpm version is pinned in many places, not one
@@ -22,10 +22,8 @@ When bumping pnpm/node, also re-check `frontend/apps/remark42/package.json`'s `e
## pnpm 10's stricter `node-linker` layout needs explicit pins
A few deps needed pinning specifically because of pnpm 10's hoisting changes, not because of the deps themselves:
- `react-intl` 6.0.5 — newer versions' type declarations break the preact-compat alias setup
One dep is pinned specifically because of pnpm 10's hoisting changes, not because of the dep itself:
- `@types/minimatch` 5.1.2 — 6.x is an empty stub that the hoisted layout picks up instead of the real types
- `cheerio` 1.0.0-rc.12 — 1.2 is ESM-only and breaks under jest 28
If a dependency bump mysteriously breaks types or module resolution only after a pnpm major bump, suspect the layout change before suspecting the dependency.
@@ -59,8 +57,7 @@ without `import { h }` would type-check and lint clean, then throw at runtime, b
These were deliberately not bumped because each is a config-migration or bundle-changing major, not a drop-in update — don't bump them opportunistically inside an unrelated dependency PR:
- `eslint` 8 (9/10 need flat-config migration), `stylelint` 14 (16 has breaking rule changes), `babel` 7, `jest` 28 (30 needs config changes)
- `react`/`react-dom` (the app uses `preact` aliased as `react`/`react-dom` via `preact/compat` — don't "fix" this by installing real react)
- `redux`/`react-redux`, `tailwindcss` 3.4 (v4 is a full config rewrite), `@11ty/eleventy` 2 (v3 is an ESM migration) in `site/`
- `redux` 4, `tailwindcss` 3.4 (v4 is a full config rewrite), `@11ty/eleventy` 2 (v3 is an ESM migration) in `site/`
## `html-minifier` is abandoned — use `html-minifier-terser`
@@ -75,3 +72,52 @@ There's no automated build-output diff in CI. Before merging a dependency PR tha
## Where the alerts actually were
When clearing Dependabot/audit alerts, check whether the flagged package is actually reachable from production code or only from the dev/test toolchain — `pnpm audit`/`yarn audit` don't distinguish. Several alerts here were in build-time-only tooling (webpack-dev-server, laravel-mix-equivalent dev deps) with no patched release available; those are lower-risk than a runtime dependency with the same severity label.
## Don't import `preact/compat`
It installs hooks on preact's shared `options` that remap `onFocus`/`onBlur` to
`focusin`/`focusout` on every element and make `@testing-library/preact` rewrite `change` to
`input`, so one import changes event behaviour across the whole widget, and it adds about
3.8 kB gzipped.
`forwardRef` lives only there, so a component that needs a ref takes it as an ordinary prop;
`TextareaAutosize` is the pattern. For a component type annotation use `FunctionComponent`
from `preact`, not `React.FC`.
## i18n is a hand-written binding whose export names are fixed by the extractor
`app/common/intl.tsx` provides `IntlProvider`, `useIntl`, `createIntl`, `defineMessages`,
`FormattedMessage` and `IntlShape`. `formatjs extract` (`translation:extract`) finds messages
by recognising the identifiers `defineMessages`, `FormattedMessage` and `intl.formatMessage`
in the AST, not by import source, so those three names are fixed. Rename any of them and
extraction returns nothing, with no error and a zero exit code.
The damage lands on the next step. `translation:check` compares locale keys against extracted
keys and fails loudly, but `tasks/generateDictionary.js` calls `removeAbandonedKeys`, so
`translation:generate` after a rename deletes the now-unextracted keys from all 24 files in
`app/locales/` and writes them back, after which the check passes. Extract-then-generate is
the documented translator workflow, so the destructive order is the normal one.
After any rename or refactor touching those identifiers, verify the count rather than the exit
code:
```sh
pnpm translation:extract
node -e "console.log(Object.keys(require('./extracted-messages/messages')).length)"
```
It must match the key count in `app/locales/en.json`, not drop to 0.
The binding implements `{name}` interpolation and paired `<tag>text</tag>` rich text, and
nothing else of ICU: no plural, select or selectordinal, no typed arguments such as
`{n, number}`, and no apostrophe quoting. A plural or select form has braces the placeholder
rule rejects, so it falls back to English wherever values are passed and shows its raw text
where they are not; `''` simply stays as two apostrophes.
`translation:check` validates every translated value's markup and placeholders against the
English string it translates, mirroring the binding's rule, and the catalogue sweep in
`app/common/intl.test.tsx` additionally renders the two messages that carry a link. What
neither catches is unsupported ICU syntax, since a plural form is well-formed text as far as
both are concerned. A message the binding cannot resolve falls back to the English source
rather than reaching the page, so without these checks a broken translation is invisible in
the interface.
+3 -3
View File
@@ -5,7 +5,7 @@ module.exports = [
},
{
path: 'public/remark.mjs',
limit: '80 KB',
limit: '60 KB',
},
{
path: 'public/remark.css',
@@ -13,7 +13,7 @@ module.exports = [
},
{
path: 'public/last-comments.mjs',
limit: '40 KB',
limit: '20 KB',
},
{
path: 'public/last-comments.css',
@@ -21,7 +21,7 @@ module.exports = [
},
{
path: 'public/deleteme.mjs',
limit: '15 KB',
limit: '9.5 KB',
},
{
path: 'public/counter.mjs',
@@ -0,0 +1,329 @@
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';
import { render, screen } from '@testing-library/preact';
import { FormattedMessage, IntlProvider, createIntl, defineMessages, useIntl } from './intl';
const descriptor = { id: 'greeting', defaultMessage: 'Hello {name}' };
describe('createIntl', () => {
it('prefers the catalogue over the default message', () => {
const intl = createIntl('ru', { greeting: 'Привет {name}' });
expect(intl.formatMessage(descriptor, { name: 'Sam' })).toBe('Привет Sam');
});
it('falls back to the default message when the key is absent', () => {
const intl = createIntl('en', {});
expect(intl.formatMessage(descriptor, { name: 'Sam' })).toBe('Hello Sam');
});
it('leaves a placeholder without a value untouched', () => {
const intl = createIntl('en', {});
expect(intl.formatMessage(descriptor)).toBe('Hello {name}');
});
it('accepts whitespace inside the braces, as ICU does', () => {
const intl = createIntl('en', { greeting: 'Hello { name }' });
expect(intl.formatMessage(descriptor, { name: 'Sam' })).toBe('Hello Sam');
});
it('treats an empty catalogue entry as missing', () => {
const intl = createIntl('ru', { greeting: '' });
expect(intl.formatMessage(descriptor, { name: 'Sam' })).toBe('Hello Sam');
});
it('degrades to the raw value when a date cannot be formatted', () => {
const intl = createIntl('en', {});
const invalid = new Date('nonsense');
expect(intl.formatDate(invalid)).toBe(String(invalid));
expect(intl.formatTime(invalid)).toBe(String(invalid));
});
it('formats time with hour and minute by default', () => {
const intl = createIntl('en', {});
const at = new Date(2026, 0, 2, 15, 4);
expect(intl.formatTime(at)).toBe(new Intl.DateTimeFormat('en', { hour: 'numeric', minute: 'numeric' }).format(at));
expect(intl.formatDate(at)).toBe(new Intl.DateTimeFormat('en').format(at));
});
// formatters are cached, so the cache key has to separate locales and option sets
it('does not confuse cached formatters across locales and options', () => {
const at = new Date(2026, 0, 2, 15, 4);
expect(createIntl('ru', {}).formatDate(at)).not.toBe(createIntl('en', {}).formatDate(at));
expect(createIntl('en', {}).formatDate(at, { dateStyle: 'full' })).not.toBe(createIntl('en', {}).formatDate(at));
expect(createIntl('en', {}).formatDate(at)).toBe(createIntl('en', {}).formatDate(at));
});
it('honours explicit time options instead of adding defaults', () => {
const intl = createIntl('en', {});
const at = new Date(2026, 0, 2, 15, 4);
expect(intl.formatTime(at, { hour: '2-digit' })).toBe(
new Intl.DateTimeFormat('en', { hour: '2-digit' }).format(at)
);
});
});
describe('defineMessages', () => {
it('returns its argument unchanged', () => {
const messages = defineMessages({ greeting: descriptor });
expect(messages.greeting).toBe(descriptor);
});
});
describe('<FormattedMessage/>', () => {
const link = { id: 'powered-by', defaultMessage: 'Powered by <a>Remark42</a>' };
function renderWith(locale: string, messages: Record<string, string>) {
return render(
<IntlProvider locale={locale} messages={messages}>
<FormattedMessage {...link} values={{ a: (chunk: string) => <a href="/x">{chunk}</a> }} />
</IntlProvider>
);
}
it('wraps the tagged chunk with the handler', () => {
const { container } = renderWith('en', {});
expect(container.textContent).toBe('Powered by Remark42');
expect(screen.getByRole('link').textContent).toBe('Remark42');
});
it('uses the translated text around the tag', () => {
const { container } = renderWith('ru', { 'powered-by': 'Работает на базе <a>Remark42</a>' });
expect(container.textContent).toBe('Работает на базе Remark42');
});
// both shapes fall back to the source rather than reaching the page
it('falls back to the default message when a tag carries attributes', () => {
const { container } = renderWith('mk', {
'powered-by': "Овозможено од <a href='diosfera.codeberg.page'>Диосфера</a>",
});
expect(container.textContent).toBe('Powered by Remark42');
});
// ro drops the link from both rich-text strings, which is valid
it('renders a translation that does not use the tag at all', () => {
const { container } = renderWith('ro', { 'powered-by': 'Cu sprijinul Remark42' });
expect(container.textContent).toBe('Cu sprijinul Remark42');
expect(container.querySelector('a')).toBeNull();
});
it('handles the same tag appearing more than once', () => {
const { container } = render(
<IntlProvider locale="en" messages={{ two: '<a>one</a> and <a>two</a>' }}>
<FormattedMessage id="two" defaultMessage="x" values={{ a: (chunk: string) => <a href="/x">{chunk}</a> }} />
</IntlProvider>
);
expect(container.textContent).toBe('one and two');
expect(container.querySelectorAll('a')).toHaveLength(2);
});
// one well-formed pair must not vouch for a stray reference elsewhere
it('falls back when a stray tag sits beside a well-formed pair', () => {
const { container } = renderWith('xx', { 'powered-by': 'A <a>B</a> C </a>' });
expect(container.textContent).toBe('Powered by Remark42');
});
// a translator inventing <b> gets English rather than visible markup
it('falls back when the translation introduces a tag nothing handles', () => {
const { container } = renderWith('xx', { 'powered-by': 'Made with <b>love</b>' });
expect(container.textContent).toBe('Powered by Remark42');
});
it('falls back to the default message when a tag is unclosed', () => {
const { container } = renderWith('th', { 'powered-by': 'ระบบแสดงความคิดเห็นโดย <a>Remark42</a' });
expect(container.textContent).toBe('Powered by Remark42');
});
});
describe('useIntl', () => {
it('throws outside of a provider', () => {
function Orphan() {
useIntl();
return null;
}
expect(() => render(<Orphan />)).toThrow('intl accessed outside of an IntlProvider');
});
});
describe('formatting details that no shipped message exercises yet', () => {
it('interpolates inside a rich-text chunk', () => {
const intl = createIntl('en', { hi: 'Hello <b>{name}</b>' });
expect(intl.formatMessage({ id: 'hi', defaultMessage: 'x' }, { name: 'Sam', b: (c: string) => c })).toEqual([
'Hello ',
'Sam',
]);
});
it('falls back to the id when there is no translation and no default', () => {
expect(createIntl('en', {}).formatMessage({ id: 'orphan.key' })).toBe('orphan.key');
});
// a pair nothing handles is a format error
it('falls back when the translation adds a pair nothing handles', () => {
const intl = createIntl('en', { k: 'See <abbr>HTML</abbr> and <a>link</a>' });
expect(intl.formatMessage({ id: 'k', defaultMessage: 'Go <a>home</a>' }, { a: (c: string) => c })).toEqual([
'Go ',
'home',
]);
});
// any tag that cannot resolve makes the whole string fall back, rather than
// putting raw markup on the page
it.each([
['an unclosed tag of another name', 'Go <a>home</a> <abbr'],
['an unclosed tag of our own name', 'Go <a>home'],
['a tag carrying an attribute', "Go <a href='x'>home</a>"],
['spaces inside the brackets', 'Go < a >home</ a >'],
['a tag nested inside a handled one', 'Go <a>ho<b>m</b>e</a>'],
])('falls back on %s', (_name, message) => {
const intl = createIntl('en', { k: message });
expect(intl.formatMessage({ id: 'k', defaultMessage: 'Go <a>home</a>' }, { a: (c: string) => c })).toEqual([
'Go ',
'home',
]);
});
it.each([
['an unclosed brace', 'Hello {name and <a>R</a>'],
['doubled braces', 'Hello {{name}} <a>R</a>'],
['a stray closing brace', 'Hello name} <a>R</a>'],
])('falls back on %s', (_name, message) => {
const intl = createIntl('en', { k: message });
expect(intl.formatMessage({ id: 'k', defaultMessage: 'Go <a>home</a>' }, { a: (c: string) => c })).toEqual([
'Go ',
'home',
]);
});
// formatjs leaves a self-closing tag as text, so it must not trigger the fallback
it('renders a self-closing tag as text', () => {
const intl = createIntl('en', { k: 'Go <a>home</a><br/>' });
expect(intl.formatMessage({ id: 'k', defaultMessage: 'default' }, { a: (c: string) => c })).toEqual([
'Go ',
'home',
'<br/>',
]);
});
it('leaves a bare less-than sign alone', () => {
const intl = createIntl('en', { k: 'Under < 10 via <a>here</a>' });
expect(intl.formatMessage({ id: 'k', defaultMessage: 'default' }, { a: (c: string) => c })).toEqual([
'Under < 10 via ',
'here',
]);
});
it('treats a non-ascii suffix as a reference to the tag, and falls back', () => {
const intl = createIntl('en', { k: '<aб>X</aб>' });
expect(intl.formatMessage({ id: 'k', defaultMessage: 'Go <a>home</a>' }, { a: (c: string) => c })).toEqual([
'Go ',
'home',
]);
});
});
describe('every shipped catalogue', () => {
const localesDir = join(__dirname, '..', 'locales');
const catalogues = readdirSync(localesDir)
.filter((file) => file.endsWith('.json'))
.map(
(file) =>
[file.replace('.json', ''), JSON.parse(readFileSync(join(localesDir, file), 'utf8'))] as [
string,
Record<string, string>
]
);
const richText = [
{ id: 'root.powered-by', defaultMessage: 'Powered by <a>Remark42</a>' },
{ id: 'commentForm.notice-about-styling', defaultMessage: 'Styling with <a>Markdown</a> is supported' },
];
// only the handled tag is stripped; a self-closing tag stays, as the binding keeps it
const withoutTags = (message: string) => message.replace(/<\/?a>/g, '');
// reads the real files, so a malformed entry added to any locale later fails here: the
// binding falls back to English on one, and the assertion below expects the locale's own
// words. structural validation of every other value is checkTranslation.js's job
it.each(catalogues)('%s renders its own words in its rich-text messages', (locale, messages) => {
const intl = createIntl(locale, messages);
richText.forEach((descriptor) => {
const rendered = ([] as unknown[])
.concat(intl.formatMessage(descriptor, { a: (chunk: string) => chunk }) as never)
.join('');
// an empty entry counts as missing, matching the binding
const translated = messages[descriptor.id] || undefined;
expect(rendered).toBe(withoutTags(translated ?? descriptor.defaultMessage));
});
});
it('formats dates and times in the widget locale rather than always in English', () => {
const at = new Date(2026, 0, 2, 15, 4);
expect(createIntl('ru', {}).formatDate(at)).not.toBe(createIntl('en', {}).formatDate(at));
expect(createIntl('ru', {}).formatTime(at)).not.toBe(createIntl('en', {}).formatTime(at));
});
});
describe('branches with no shipped input yet', () => {
it('renders the broken message when there is nothing to fall back to', () => {
const intl = createIntl('xx', { k: 'Broken <a>link</a' });
expect(intl.formatMessage({ id: 'k' }, { a: (c: string) => c })).toEqual(['Broken <a>link</a']);
});
it('does not interpolate a placeholder whose value is a handler', () => {
const intl = createIntl('en', { k: '{a} and <a>link</a>' });
expect(intl.formatMessage({ id: 'k', defaultMessage: 'x' }, { a: (c: string) => c })).toEqual(['{a} and ', 'link']);
});
it('escapes a tag name that carries a regular-expression metacharacter', () => {
const intl = createIntl('en', { k: 'a.b <a.b>x</a.b> c' });
expect(() => intl.formatMessage({ id: 'k', defaultMessage: 'y' }, { 'a.b': (c: string) => c })).not.toThrow();
});
it.each([['minute'], ['second'], ['dateStyle'], ['timeStyle']])(
'does not add hour and minute when %s is given',
(option) => {
const intl = createIntl('en', {});
const at = new Date(2026, 0, 2, 15, 4);
const options = { [option]: option === 'dateStyle' || option === 'timeStyle' ? 'short' : 'numeric' };
expect(intl.formatTime(at, options as Intl.DateTimeFormatOptions)).toBe(
new Intl.DateTimeFormat('en', options as Intl.DateTimeFormatOptions).format(at)
);
}
);
});
+253
View File
@@ -0,0 +1,253 @@
import { createContext, Fragment, type ComponentChildren } from 'preact';
import { useContext } from 'preact/hooks';
/**
* Minimal i18n binding over Preact context.
*
* Only what the widget uses: a provider, a hook, message formatting with `{name}`
* interpolation and `<tag>chunk</tag>` rich text, and date/time formatting through
* `Intl.DateTimeFormat`.
*
* `formatjs extract` finds messages by recognising the identifiers `defineMessages`,
* `FormattedMessage` and `intl.formatMessage` in the AST rather than by import source,
* so those three names are fixed. Renaming one silently empties the extracted catalogue.
*
* A message the binding cannot parse falls back to the message in the source: a broken,
* unhandled or nested tag, a brace that is not a well-formed placeholder, and a
* placeholder naming a value the caller did not supply.
*
* What this deliberately does not implement, none of which any of the 24 catalogues
* uses: ICU plural, select and selectordinal; typed arguments such as `{n, number}`
* or `{d, date}`; and ICU apostrophe quoting. A plural or select form carries braces the
* placeholder rule rejects, so it falls back wherever values are passed and renders raw
* where they are not. Quoting is not interpreted at all: `''` stays two apostrophes and
* `'{name}'` interpolates with the quotes in place. Add support before accepting one.
*/
type MessageDescriptor = {
id: string;
defaultMessage?: string;
description?: string;
};
/** A rich-text handler, wrapping the text between a matching pair of tags. */
type ChunkFormatter = (chunk: string) => ComponentChildren;
type PrimitiveValues = Record<string, string | number>;
type MessageValues = Record<string, string | number | ChunkFormatter>;
export type IntlShape = {
locale: string;
formatMessage(descriptor: MessageDescriptor, values?: PrimitiveValues): string;
formatMessage(descriptor: MessageDescriptor, values: MessageValues): ComponentChildren;
formatDate(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
formatTime(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
};
/**
* Identity function. Its first type parameter is the key type rather than the record,
* so callers can constrain keys explicitly, as in `defineMessages<string>`.
*/
export function defineMessages<K extends PropertyKey, T = MessageDescriptor, U extends Record<K, T> = Record<K, T>>(
messages: U
): U {
return messages;
}
const PLACEHOLDER = /\{\s*(\w+)\s*\}/g;
/** Replaces `{name}`, tolerating whitespace inside the braces, and leaves a placeholder without a value untouched. */
function interpolate(message: string, values: MessageValues): string {
return message.replace(PLACEHOLDER, (match, key: string) => {
const value = values[key];
return value === undefined || typeof value === 'function' ? match : String(value);
});
}
/** Escapes a tag name so a metacharacter in it cannot change or break the pattern. */
function escapeForRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Matches a well-formed pair for any of the given tag names, capturing the name and the chunk. */
function pairMatcher(tags: string[], flags = ''): RegExp {
return new RegExp(`<(${tags.map(escapeForRegExp).join('|')})>([\\s\\S]*?)</\\1>`, flags);
}
/** A self-closing tag is text, not markup, matching formatjs. */
const SELF_CLOSING = /<[a-zA-Z][\w-]*\s*\/>/g;
/** Any `</`, or a `<` opening a tag name, is markup that has to resolve. */
const TAG_START = /<\/|<[a-zA-Z]/;
/**
* True when a brace is not part of a well-formed placeholder, or a well-formed one names
* a value the caller did not supply. Both are errors, and the message falls back.
*/
function hasBrokenPlaceholders(message: string, values: MessageValues): boolean {
if (/[{}]/.test(message.replace(PLACEHOLDER, ''))) {
return true;
}
for (const match of message.matchAll(PLACEHOLDER)) {
if (!(match[1] in values)) {
return true;
}
}
return false;
}
/**
* True when anything tag-shaped survives once the handled pairs are taken out.
*
* A broken tag, an unhandled one, or one nested inside another makes the whole string
* fall back to the source message rather than reaching the page as raw markup. The
* chunks are checked as well as the text around them, because stripping a pair
* removes whatever was nested inside it.
*/
function hasBrokenMarkup(message: string, tags: string[]): boolean {
const chunks: string[] = [];
// an empty tag list would build an empty alternation, which matches `<></>`
const withoutPairs =
tags.length === 0
? message
: message.replace(pairMatcher(tags, 'g'), (_match, _name: string, chunk: string) => {
chunks.push(chunk);
return '';
});
const residue = withoutPairs.replace(SELF_CLOSING, '');
return TAG_START.test(residue) || chunks.some((chunk) => TAG_START.test(chunk.replace(SELF_CLOSING, '')));
}
/** True when the message cannot be parsed and the source message should be used instead. */
function isBroken(message: string, values: MessageValues, tags: string[]): boolean {
return hasBrokenMarkup(message, tags) || hasBrokenPlaceholders(message, values);
}
/** Splits a message into text and the results of its rich-text handlers. */
function formatRich(message: string, values: MessageValues, tags: string[]): ComponentChildren[] {
const parts: ComponentChildren[] = [];
let cursor = 0;
for (const match of message.matchAll(pairMatcher(tags, 'g'))) {
const start = match.index as number;
if (start > cursor) {
parts.push(interpolate(message.slice(cursor, start), values));
}
parts.push((values[match[1]] as ChunkFormatter)(interpolate(match[2], values)));
cursor = start + match[0].length;
}
if (cursor < message.length) {
parts.push(interpolate(message.slice(cursor), values));
}
return parts;
}
function format(message: string, fallback: string | undefined, values?: MessageValues): ComponentChildren {
if (!values) {
return message;
}
const tags = Object.keys(values).filter((key) => typeof values[key] === 'function');
// a message that cannot be parsed falls back to the source rather than reaching the
// page. this has to run whether or not the message carries rich text, since a mistyped
// placeholder is the likelier mistake
const source = isBroken(message, values, tags) && fallback !== undefined ? fallback : message;
return tags.length === 0 ? interpolate(source, values) : formatRich(source, values, tags);
}
/**
* `Intl.DateTimeFormat` is expensive to construct and every comment formats a date and a
* time, so instances are reused.
*/
const formatters = new Map<string, Intl.DateTimeFormat>();
function dateTimeFormat(locale: string, options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
const key = `${locale}\u0000${options ? JSON.stringify(options) : ''}`;
let formatter = formatters.get(key);
if (!formatter) {
formatter = new Intl.DateTimeFormat(locale, options);
formatters.set(key, formatter);
}
return formatter;
}
/** Formats a date, degrading to the raw value rather than throwing mid-render. */
function formatWith(locale: string, value: Date | number, options?: Intl.DateTimeFormatOptions): string {
try {
return dateTimeFormat(locale, options).format(value);
} catch {
return String(value);
}
}
export function createIntl(locale: string, messages: Record<string, string>): IntlShape {
function formatMessage(descriptor: MessageDescriptor, values?: MessageValues) {
// the catalogue wins, then the message in the source. english ships an empty
// catalogue, so defaultMessage is the primary path rather than a fallback.
// an empty entry counts as missing
const message = messages[descriptor.id] || descriptor.defaultMessage || descriptor.id;
return format(message, descriptor.defaultMessage, values);
}
return {
formatMessage: formatMessage as IntlShape['formatMessage'],
locale,
formatDate(value, options) {
return formatWith(locale, value, options);
},
formatTime(value, options) {
// hour and minute are added unless the caller asked for a time component itself;
// every call here passes no options at all
const needsDefaults =
!options?.hour && !options?.minute && !options?.second && !options?.dateStyle && !options?.timeStyle;
const resolved: Intl.DateTimeFormatOptions = needsDefaults
? { ...options, hour: 'numeric', minute: 'numeric' }
: options;
return formatWith(locale, value, resolved);
},
};
}
const IntlContext = createContext<IntlShape | null>(null);
export function IntlProvider({
locale,
messages,
children,
}: {
locale: string;
messages: Record<string, string>;
children?: ComponentChildren;
}) {
return <IntlContext.Provider value={createIntl(locale, messages)}>{children}</IntlContext.Provider>;
}
export function useIntl(): IntlShape {
const intl = useContext(IntlContext);
if (!intl) {
throw new Error('intl accessed outside of an IntlProvider');
}
return intl;
}
export function FormattedMessage({ values, ...descriptor }: MessageDescriptor & { values?: MessageValues }) {
const intl = useIntl();
return <Fragment>{intl.formatMessage(descriptor, values ?? {})}</Fragment>;
}
@@ -1,9 +1,9 @@
import { mount } from 'enzyme';
import { render } from '@testing-library/preact';
import createMockStore from 'redux-mock-store';
import { Middleware } from 'redux';
import { Provider } from 'store/context';
import { IntlProvider } from 'react-intl';
import { IntlProvider } from 'common/intl';
import type { User } from 'common/types';
import enMessages from 'locales/en.json';
@@ -32,7 +32,7 @@ const mockStore = createMockStore([] as Middleware[]);
describe('<AuthPanel />', () => {
const createWrapper = (props: Props = DefaultProps, store: ReturnType<typeof mockStore> = mockStore(initialStore)) =>
mount(
render(
<IntlProvider locale="en" messages={enMessages}>
<Provider store={store}>
<AuthPanel {...props} />
@@ -42,57 +42,53 @@ describe('<AuthPanel />', () => {
describe('For not authorized : null', () => {
it('should not render settings if there is no hidden users', () => {
const element = createWrapper({
const { container } = createWrapper({
...DefaultProps,
user: null,
postInfo: { ...DefaultProps.postInfo, read_only: true },
} as Props);
const adminAction = element.find(`.${styles.adminAction}`);
expect(adminAction.exists()).toBe(false);
expect(container.querySelector(`.${styles.adminAction}`)).toBeNull();
});
it('should render settings if there is some hidden users', () => {
const element = createWrapper({
const { container } = createWrapper({
...DefaultProps,
user: null,
postInfo: { ...DefaultProps.postInfo, read_only: true },
hiddenUsers: { hidden_joe: {} as User },
} as Props);
const adminAction = element.find(`.${styles.adminAction}`).first();
const adminAction = container.querySelector(`.${styles.adminAction}`);
expect(adminAction.text()).toEqual('Show settings');
expect(adminAction?.textContent).toEqual('Show settings');
});
});
describe('For authorized user', () => {
it('should render info about current user', () => {
const element = createWrapper({
const { container } = createWrapper({
...DefaultProps,
user: { id: 'john', name: 'John' },
} as Props);
const authPanelColumn = element.find(`.${styles.column}`);
const authPanelColumn = container.querySelectorAll(`.${styles.column}`);
expect(authPanelColumn.length).toEqual(2);
const userInfo = authPanelColumn.first();
expect(userInfo.text()).toEqual(expect.stringContaining('John'));
expect(authPanelColumn).toHaveLength(2);
expect(authPanelColumn[0].textContent).toEqual(expect.stringContaining('John'));
});
});
describe('For admin user', () => {
it('should render admin action', () => {
const element = createWrapper({
const { container } = createWrapper({
...DefaultProps,
user: { id: 'test', admin: true, name: 'John' },
} as Props);
const adminAction = element.find(`.${styles.adminAction}`).first();
const adminAction = container.querySelector(`.${styles.adminAction}`);
expect(adminAction.text()).toEqual('Show settings');
expect(adminAction?.textContent).toEqual('Show settings');
});
});
});
@@ -1,7 +1,7 @@
import { h, Component } from 'preact';
import { FormattedMessage, IntlShape, useIntl } from 'react-intl';
import clsx from 'clsx';
import { FormattedMessage, IntlShape, useIntl } from 'common/intl';
import { User, Theme, PostInfo } from 'common/types';
import { IS_STORAGE_AVAILABLE, IS_THIRD_PARTY } from 'common/constants';
import { postMessageToParent } from 'utils/post-message';
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, useMemo } from 'preact/hooks';
import { useIntl } from 'react-intl';
import { useIntl } from 'common/intl';
import { errorMessages, RequestError } from 'utils/errorUtils';
import { isObject } from 'utils/is-object';
import { parseMessage, updateIframeHeight } from 'utils/post-message';
@@ -1,4 +1,4 @@
import { defineMessages } from 'react-intl';
import { defineMessages } from 'common/intl';
export const messages = defineMessages<string>({
signin: {
@@ -1,9 +1,9 @@
import clsx from 'clsx';
import { h, Fragment, JSX } from 'preact';
import { useState, useRef } from 'preact/hooks';
import { useIntl } from 'react-intl';
import { useDispatch } from 'store/context';
import { useIntl } from 'common/intl';
import { setUser } from 'store/user/actions';
import { Input } from 'components/input';
import { TelegramLink } from 'components/telegram/telegram-link';
@@ -1,7 +1,7 @@
import { h, JSX } from 'preact';
import clsx from 'clsx';
import { useIntl } from 'react-intl';
import { useIntl } from 'common/intl';
import type { OAuthProvider } from 'common/types';
import { siteId } from 'common/settings';
import { useTheme } from 'hooks/useTheme';
@@ -1,6 +1,5 @@
import clsx from 'clsx';
import { h, type ButtonHTMLAttributes } from 'preact';
import { forwardRef } from 'preact/compat';
import type { Theme } from 'common/types';
@@ -25,8 +24,8 @@ export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'size' |
className?: string;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ children, theme, mix, kind, type = 'button', size, className, ...props }, ref) => (
export function Button({ children, theme, mix, kind, type = 'button', size, className, ...props }: ButtonProps) {
return (
<button
className={clsx(
styles.root,
@@ -38,9 +37,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
)}
type={type}
{...props}
ref={ref}
>
{children}
</button>
)
);
);
}
@@ -1,9 +1,8 @@
import { mount } from 'enzyme';
import '@testing-library/jest-dom';
import { fireEvent, screen } from '@testing-library/preact';
import { act } from 'preact/test-utils';
import { Provider } from 'store/context';
import { Middleware } from 'redux';
import createMockStore from 'redux-mock-store';
import { IntlProvider } from 'react-intl';
import { render } from 'tests/utils';
jest.mock('common/api');
@@ -11,11 +10,9 @@ import { user, anonymousUser } from '__stubs__/user';
import { validToken } from '__stubs__/jwt';
import { emailVerificationForSubscribe, emailConfirmationForSubscribe, unsubscribeFromEmailUpdates } from 'common/api';
import { sleep } from 'utils/sleep';
import { Input } from 'components/input';
import { Button } from 'components/button';
import { Dropdown } from 'components/dropdown';
import { persistEmail } from 'components/auth/auth.utils';
import enMessages from 'locales/en.json';
import { StoreState } from 'store';
import { SubscribeByEmail, SubscribeByEmailForm } from '.';
import { RequestError } from '../../../utils/errorUtils';
@@ -38,126 +35,105 @@ const initialStore = {
theme: 'light',
} as const;
const mockStore = createMockStore([] as Middleware[]);
const makeInputEvent = (value: string) => ({
preventDefault: jest.fn(),
target: {
value,
},
});
jest.mock('utils/jwt', () => ({
isJwtExpired: jest.fn(() => false),
}));
// call history accumulates across the file otherwise, so a toHaveBeenCalledWith can be
// satisfied by an earlier test. only the history is cleared, since the module-scope
// mockImplementation above has to survive into every test
beforeEach(() => {
emailVerificationForSubscribeMock.mockClear();
emailConfirmationForSubscribeMock.mockClear();
unsubscribeFromEmailUpdatesMock.mockClear();
});
describe('<SubscribeByEmail/>', () => {
const createWrapper = (store: ReturnType<typeof mockStore> = mockStore(initialStore)) =>
mount(
<IntlProvider locale="en" messages={enMessages}>
<Provider store={store}>
<SubscribeByEmail />
</Provider>
</IntlProvider>
);
const createWrapper = (state: Partial<StoreState> = initialStore) => render(<SubscribeByEmail />, state);
it('should be rendered with disabled email button when user is anonymous', () => {
const store = mockStore({ ...initialStore, user: anonymousUser });
const wrapper = createWrapper(store);
const dropdown = wrapper.find(Dropdown);
const { container } = createWrapper({ ...initialStore, user: anonymousUser });
const button = screen.getByRole('button');
expect(dropdown.prop('disabled')).toEqual(true);
expect(dropdown.prop('buttonTitle')).toEqual('Available only for registered users');
expect(container.querySelectorAll('button')).toHaveLength(1);
expect(button).toBeDisabled();
expect(button.getAttribute('title')).toEqual('Available only for registered users');
});
it('should be rendered with enabled email button when user is registrated', () => {
const store = mockStore(initialStore);
const wrapper = createWrapper(store);
const dropdown = wrapper.find(Dropdown);
const { container } = createWrapper(initialStore);
const button = screen.getByRole('button');
expect(dropdown.prop('disabled')).toEqual(false);
expect(dropdown.prop('buttonTitle')).toEqual('Subscribe by Email');
expect(container.querySelectorAll('button')).toHaveLength(1);
expect(button).not.toBeDisabled();
expect(button.getAttribute('title')).toEqual('Subscribe by Email');
});
});
describe('<SubscribeByEmailForm/>', () => {
const createWrapper = (store: ReturnType<typeof mockStore> = mockStore(initialStore)) =>
mount(
<IntlProvider locale="en" messages={enMessages}>
<Provider store={store}>
<SubscribeByEmailForm />
</Provider>
</IntlProvider>
);
it('should render email form by default', () => {
const store = mockStore(initialStore);
const wrapper = createWrapper(store);
const title = wrapper.find(`.${styles.title}`);
const button = wrapper.find(Button);
const createWrapper = (state: Partial<StoreState> = initialStore) => render(<SubscribeByEmailForm />, state);
expect(title.text()).toEqual('Subscribe to replies');
expect(button.prop('children')).toEqual('Submit');
expect(button.prop('disabled')).toEqual(true);
it('should render email form by default', () => {
const { container } = createWrapper(initialStore);
expect(container.querySelector(`.${styles.title}`)?.textContent).toEqual('Subscribe to replies');
expect(container.querySelectorAll('button')).toHaveLength(1);
expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled();
});
it('should render subscribed state if user subscribed', () => {
const store = mockStore({ ...initialStore, user: { email_subscription: true } });
const wrapper = createWrapper(store);
const { container } = createWrapper({ ...initialStore, user: { ...user, email_subscription: true } });
expect(wrapper.find(`.${styles.subscribed}`)).toHaveLength(1);
expect(wrapper.text().startsWith('You are subscribed on updates by email')).toBe(true);
expect(container.querySelectorAll(`.${styles.subscribed}`)).toHaveLength(1);
expect(container.querySelectorAll('button')).toHaveLength(1);
expect(container.textContent?.startsWith('You are subscribed on updates by email')).toBe(true);
});
it('should pass through subscribe process', async () => {
const wrapper = createWrapper();
const { container } = createWrapper();
const form = container.querySelector('form') as HTMLFormElement;
const input = container.querySelector('input') as HTMLInputElement;
const input = wrapper.find('input');
const form = wrapper.find('form');
input.getDOMNode<HTMLInputElement>().value = 'some@email.com';
input.simulate('input');
form.simulate('submit');
fireEvent.input(input, { target: { value: 'some@email.com' } });
fireEvent.submit(form);
expect(emailVerificationForSubscribeMock).toHaveBeenCalledWith('some@email.com');
await sleep();
wrapper.update();
await act(() => sleep(0));
const textarea = wrapper.find('textarea');
const button = wrapper.find('button');
// order matters here, and a lookup by name would not catch a swap
const buttons = container.querySelectorAll('button');
expect(button.at(0).text()).toEqual('Back');
expect(button.at(1).text()).toEqual('Subscribe');
expect(buttons).toHaveLength(2);
expect(buttons[0].textContent).toBe('Back');
expect(buttons[1].textContent).toBe('Subscribe');
textarea.getDOMNode<HTMLTextAreaElement>().value = 'tokentokentoken';
textarea.simulate('input');
form.simulate('submit');
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
fireEvent.input(textarea, { target: { value: 'tokentokentoken' } });
fireEvent.submit(container.querySelector('form') as HTMLFormElement);
expect(emailConfirmationForSubscribeMock).toHaveBeenCalledWith('tokentokentoken');
await sleep(0);
wrapper.update();
await act(() => sleep(0));
expect(wrapper.text().startsWith('You have been subscribed on updates by email')).toBe(true);
expect(wrapper.find(Button).text()).toEqual('Unsubscribe');
expect(container.textContent?.startsWith('You have been subscribed on updates by email')).toBe(true);
expect(container.querySelectorAll('button')).toHaveLength(1);
expect(screen.getByRole('button', { name: 'Unsubscribe' })).toBeTruthy();
});
it('should handle http error 409: already subscribed', async () => {
emailVerificationForSubscribeMock.mockImplementationOnce(() => Promise.reject(new RequestError('', 409)));
const wrapper = createWrapper();
const { container } = createWrapper();
const form = container.querySelector('form') as HTMLFormElement;
const input = container.querySelector('input') as HTMLInputElement;
const input = wrapper.find('input');
const form = wrapper.find('form');
fireEvent.input(input, { target: { value: 'some@email.com' } });
fireEvent.submit(form);
input.getDOMNode<HTMLInputElement>().value = 'some@email.com';
input.simulate('input');
form.simulate('submit');
await act(() => sleep(0));
await sleep();
wrapper.update();
expect(wrapper.text().startsWith('You are subscribed on updates by email')).toBe(true);
expect(container.textContent?.startsWith('You are subscribed on updates by email')).toBe(true);
});
it('should pass through subscribe process without confirmation', async () => {
@@ -165,74 +141,63 @@ describe('<SubscribeByEmailForm/>', () => {
Promise.resolve({ address: email, updated: true })
);
const wrapper = createWrapper();
const { container } = createWrapper();
const form = container.querySelector('form') as HTMLFormElement;
const input = container.querySelector('input') as HTMLInputElement;
const input = wrapper.find('input');
const form = wrapper.find('form');
input.getDOMNode<HTMLInputElement>().value = 'some@email.com';
input.simulate('input');
form.simulate('submit');
fireEvent.input(input, { target: { value: 'some@email.com' } });
fireEvent.submit(form);
expect(emailVerificationForSubscribeMock).toHaveBeenCalledWith('some@email.com');
await sleep();
wrapper.update();
await act(() => sleep(0));
expect(wrapper.text().startsWith('You have been subscribed on updates by email')).toBe(true);
expect(wrapper.find(Button).text()).toEqual('Unsubscribe');
expect(container.textContent?.startsWith('You have been subscribed on updates by email')).toBe(true);
expect(container.querySelectorAll('button')).toHaveLength(1);
expect(screen.getByRole('button', { name: 'Unsubscribe' })).toBeTruthy();
});
it('should fill in email from local storage', async () => {
const expected = 'someone@email.com';
persistEmail(expected);
const wrapper = createWrapper();
const form = wrapper.find('form');
expect(form.find('input').props().value).toBe(expected);
persistEmail(expected);
const { container } = createWrapper();
expect(container.querySelector('input')?.value).toBe(expected);
});
it('should send form by paste valid token', async () => {
const wrapper = createWrapper();
const onInputEmail = wrapper.find(Input).prop('onInput') as Function;
const form = wrapper.find('form');
const { container } = createWrapper();
const input = container.querySelector('input') as HTMLInputElement;
expect(typeof onInputEmail === 'function').toBe(true);
fireEvent.input(input, { target: { value: 'some@email.com' } });
fireEvent.submit(container.querySelector('form') as HTMLFormElement);
act(() => onInputEmail(makeInputEvent('some@email.com')));
await act(() => sleep(0));
form.simulate('submit');
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
await sleep(0);
wrapper.update();
fireEvent.input(textarea, { target: { value: validToken } });
const textarea = wrapper.find('textarea');
await act(() => sleep(0));
textarea.getDOMNode<HTMLTextAreaElement>().value = validToken;
textarea.simulate('input');
await sleep(0);
wrapper.update();
expect(wrapper.text().startsWith('You have been subscribed on updates by email')).toBe(true);
expect(wrapper.find(Button).text()).toEqual('Unsubscribe');
expect(container.textContent?.startsWith('You have been subscribed on updates by email')).toBe(true);
expect(container.querySelectorAll('button')).toHaveLength(1);
expect(screen.getByRole('button', { name: 'Unsubscribe' })).toBeTruthy();
});
it('should pass throw unsubscribe process', async () => {
const store = mockStore({ ...initialStore, user: { email_subscription: true } });
const wrapper = createWrapper(store);
const onClick = wrapper.find(Button).prop('onClick') as Function;
const { container } = createWrapper({ ...initialStore, user: { ...user, email_subscription: true } });
expect(typeof onClick === 'function').toBe(true);
act(() => onClick());
fireEvent.click(screen.getByRole('button', { name: 'Unsubscribe' }));
expect(unsubscribeFromEmailUpdatesMock).toHaveBeenCalled();
await sleep(0);
wrapper.update();
await act(() => sleep(0));
expect(wrapper.text().startsWith('You have been unsubscribed by email to updates')).toBe(true);
expect(wrapper.find(Button).text()).toEqual('Close');
expect(container.textContent?.startsWith('You have been unsubscribed by email to updates')).toBe(true);
expect(container.querySelectorAll('button')).toHaveLength(1);
expect(screen.getByRole('button', { name: 'Close' })).toBeTruthy();
});
});
@@ -2,8 +2,8 @@ import { h, FunctionComponent, Fragment } from 'preact';
import { useState, useCallback, useRef } from 'preact/hooks';
import { useSelector, useDispatch } from 'store/context';
import clsx from 'clsx';
import { useIntl, defineMessages, IntlShape, FormattedMessage } from 'react-intl';
import { useIntl, defineMessages, IntlShape, FormattedMessage } from 'common/intl';
import { User } from 'common/types';
import { StoreState } from 'store';
import { setUserSubscribed } from 'store/user/actions';
@@ -1,34 +1,33 @@
import { shallow } from 'enzyme';
import { fireEvent, screen } from '@testing-library/preact';
import { render } from 'tests/utils';
import { SubscribeByRSS, createSubscribeUrl } from '.';
import styles from './subscribe-by-rss.module.css';
jest.mock('store/context', () => ({
useSelector: jest.fn((fn) => fn({ theme: 'light' })),
}));
/** Renders the widget and opens the dropdown, since the links only exist while it is open. */
function renderOpened() {
const result = render(<SubscribeByRSS userId="user-1" />, { theme: 'light' });
jest.mock('react-intl', () => {
const messages = require('locales/en.json');
const reactIntl = jest.requireActual('react-intl');
const intlProvider = new reactIntl.IntlProvider({ locale: 'en', messages }, {});
// the toggle is the only button until the dropdown opens, so this does not depend
// on the button's title copy
fireEvent.click(screen.getByRole('button'));
return {
...reactIntl,
useIntl: () => intlProvider.state.intl,
};
});
return result;
}
describe('<SubscribeByRSS/>', () => {
it('should be render links in dropdown', () => {
const wrapper = shallow(<SubscribeByRSS userId="user-1" />);
const { container } = renderOpened();
expect(wrapper.find(`.${styles.link}`)).toHaveLength(3);
expect(container.querySelectorAll(`.${styles.link}`)).toHaveLength(3);
});
it('should have userId in replies link', () => {
const wrapper = shallow(<SubscribeByRSS userId="user-1" />);
const { container } = renderOpened();
const links = container.querySelectorAll(`.${styles.link}`);
expect(wrapper.find(`.${styles.link}`).at(2).prop('href')).toBe(createSubscribeUrl('reply', '&user=user-1'));
expect(links[2].getAttribute('href')).toBe(createSubscribeUrl('reply', '&user=user-1'));
});
});
@@ -1,7 +1,7 @@
import { h, FunctionComponent } from 'preact';
import { useMemo } from 'preact/hooks';
import { useIntl, defineMessages } from 'react-intl';
import { useIntl, defineMessages } from 'common/intl';
import { useTheme } from 'hooks/useTheme';
import { siteId, url } from 'common/settings';
import { BASE_URL, API_BASE } from 'common/constants';
@@ -2,8 +2,8 @@ import clsx from 'clsx';
import { h, FunctionComponent, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { useSelector } from 'store/context';
import { useIntl, defineMessages } from 'react-intl';
import { useIntl, defineMessages } from 'common/intl';
import { User } from 'common/types';
import { StoreState } from 'store';
import { FetcherError, RequestError, extractErrorMessageFromResponse } from 'utils/errorUtils';
@@ -1,7 +1,7 @@
import '@testing-library/jest-dom';
import { fireEvent, screen, waitFor } from '@testing-library/preact';
import { useIntl } from 'react-intl';
import { useIntl } from 'common/intl';
import { render } from 'tests/utils';
import { StaticStore } from 'common/static-store';
import * as localStorageModule from 'common/local-storage';
@@ -1,7 +1,7 @@
import { h, Component, createRef, Fragment } from 'preact';
import { FormattedMessage, IntlShape, defineMessages } from 'react-intl';
import clsx from 'clsx';
import { FormattedMessage, IntlShape, defineMessages } from 'common/intl';
import { User, Theme, Image } from 'common/types';
import { StaticStore } from 'common/static-store';
import * as settings from 'common/settings';
@@ -454,7 +454,7 @@ export class CommentForm extends Component<Props, State> {
<TextExpander>
<TextareaAutosize
id={this.textareaId}
ref={this.textareaRef}
textareaRef={this.textareaRef}
onPaste={this.onPaste}
className={styles.field}
placeholder={placeholderMessage}
@@ -1,8 +1,8 @@
import '@ungap/custom-elements';
import '@github/markdown-toolbar-element';
import { h, Component } from 'preact';
import { defineMessages, IntlShape } from 'react-intl';
import { defineMessages, IntlShape } from 'common/intl';
import styles from './markdown-toolbar.module.css';
// TODO: Use SVGR
@@ -1,7 +1,7 @@
import clsx from 'clsx';
import { h, Fragment } from 'preact';
import { defineMessages, useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'common/intl';
import { BlockTTL } from 'common/types';
import { Select } from 'components/select';
import { Countdown } from 'components/countdown';
@@ -1,8 +1,8 @@
import clsx from 'clsx';
import { h } from 'preact';
import { useState } from 'preact/hooks';
import { defineMessages, useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'common/intl';
import { useDispatch } from 'store/context';
import { patchComment } from 'store/comments/actions';
import { putCommentVote } from 'common/api';
@@ -1,8 +1,8 @@
import { h } from 'preact';
import '@testing-library/jest-dom';
import { fireEvent, screen, waitFor } from '@testing-library/preact';
import { useIntl, IntlShape } from 'react-intl';
import { useIntl, IntlShape } from 'common/intl';
import { render } from 'tests/utils';
import { StaticStore } from 'common/static-store';
@@ -1,7 +1,7 @@
import { h, JSX, Component, createRef, ComponentType } from 'preact';
import { FormattedMessage, IntlShape, defineMessages } from 'react-intl';
import clsx from 'clsx';
import { FormattedMessage, IntlShape, defineMessages } from 'common/intl';
import { COMMENT_NODE_CLASSNAME_PREFIX } from 'common/constants';
import { StaticStore } from 'common/static-store';
@@ -17,7 +17,7 @@ import { uploadImage, getPreview } from 'common/api';
import { getThreadIsCollapsed } from 'store/thread/getters';
import { bindActions } from 'utils/actionBinder';
import { useActions } from 'hooks/useAction';
import { useIntl } from 'react-intl';
import { useIntl } from 'common/intl';
type ProvidedProps = Pick<
CommentProps,
@@ -1,4 +1,4 @@
import { defineMessages, IntlShape } from 'react-intl';
import { defineMessages, IntlShape } from 'common/intl';
import { BlockTTL } from 'common/types';
export interface BlockingDuration {
@@ -1,6 +1,6 @@
import { h } from 'preact';
import { useIntl } from 'react-intl';
import { useIntl } from 'common/intl';
import type { Comment as CommentType } from 'common/types';
import { Comment } from 'components/comment';
@@ -1,7 +1,7 @@
import { h } from 'preact';
import { h, type FunctionComponent } from 'preact';
import styles from './counter.module.css';
export const Counter: React.FC = ({ children }) => {
export const Counter: FunctionComponent = ({ children }) => {
return (
<div className={styles.container} data-testid="comments-counter">
{children}
@@ -1,8 +1,8 @@
import clsx from 'clsx';
import { h, Fragment } from 'preact';
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useIntl, FormattedMessage } from 'react-intl';
import { useIntl, FormattedMessage } from 'common/intl';
import { getUserComments } from 'common/api';
import { parseQuery } from 'utils/parse-query';
import { requestDeletion } from 'utils/email';
@@ -1,8 +1,8 @@
import { h, Component, Fragment } from 'preact';
import { useSelector } from 'store/context';
import { IntlShape, useIntl, FormattedMessage, defineMessages } from 'react-intl';
import clsx from 'clsx';
import { IntlShape, useIntl, FormattedMessage, defineMessages } from 'common/intl';
import 'styles/global.css';
import type { StoreState } from 'store';
import { COMMENT_NODE_CLASSNAME_PREFIX, MAX_SHOWN_ROOT_COMMENTS, THEMES, IS_MOBILE } from 'common/constants';
@@ -4,7 +4,7 @@ import clsx from 'clsx';
import { User, BlockedUser, Theme, BlockTTL } from 'common/types';
import { getHandleClickProps } from 'common/accessibility';
import { StoreState } from 'store';
import { defineMessages, IntlShape, FormattedMessage, useIntl } from 'react-intl';
import { defineMessages, IntlShape, FormattedMessage, useIntl } from 'common/intl';
import { useTheme } from 'hooks/useTheme';
import styles from './settings.module.css';
@@ -37,10 +37,7 @@ describe('<SortPicker />', () => {
expect(select).toBeInTheDocument();
// @testing-library/preact rewrites change to input for every element once it sees a
// compat vnode, so fireEvent.change never reaches a select handler; dispatch directly
select.value = nextOption;
fireEvent(select, new Event('change', { bubbles: true }));
fireEvent.change(select, { target: { value: nextOption } });
await waitFor(() => expect(updateSorting).toHaveBeenCalledWith(nextOption));
@@ -1,7 +1,7 @@
import { h } from 'preact';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { useMemo } from 'preact/hooks';
import { FormattedMessage, defineMessages, useIntl } from 'common/intl';
import { StoreState, useAppDispatch, useAppSelector } from 'store';
import { Select } from 'components/select';
import { updateSorting } from 'store/comments/actions';
@@ -1,8 +1,8 @@
import clsx from 'clsx';
import { h } from 'preact';
import { messages } from 'components/auth/auth.messages';
import { useIntl } from 'react-intl';
import { useIntl } from 'common/intl';
import styles from './spinner.module.css';
type Props = {
@@ -1,10 +1,10 @@
import clsx from 'clsx';
import { h, Fragment, FunctionComponent } from 'preact';
import { useIntl } from 'react-intl';
import { messages } from './telegram.messages';
import { BASE_URL, API_BASE } from 'common/constants.config';
import { Button } from 'components/button';
import { useIntl } from 'common/intl';
import styles from './telegram-link.module.css';
export type TelegramLinkProps = {
@@ -1,4 +1,4 @@
import { defineMessages } from 'react-intl';
import { defineMessages } from 'common/intl';
export const messages = defineMessages<string>({
telegramMessage1: {
@@ -0,0 +1,21 @@
import { createRef } from 'preact';
import { render } from '@testing-library/preact';
import { TextareaAutosize } from './textarea-autosize';
describe('<TextareaAutosize/>', () => {
it('points the given ref at the textarea', () => {
const ref = createRef<HTMLTextAreaElement>();
render(<TextareaAutosize id="t" textareaRef={ref} value="hello" />);
expect(ref.current).toBeInstanceOf(HTMLTextAreaElement);
expect(ref.current?.value).toBe('hello');
});
it('works without a ref', () => {
const { container } = render(<TextareaAutosize id="t" value="hi" />);
expect(container.querySelector('textarea')?.value).toBe('hi');
});
});
@@ -1,20 +1,20 @@
import { h, JSX, type TextareaHTMLAttributes } from 'preact';
import { forwardRef } from 'preact/compat';
import { useEffect, useImperativeHandle, useRef } from 'preact/hooks';
import { h, JSX, type RefObject, type TextareaHTMLAttributes } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
function autoResize(textarea: HTMLTextAreaElement) {
textarea.style.height = '';
textarea.style.height = `${textarea.scrollHeight}px`;
}
type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'onInput'> & {
type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'onInput' | 'ref'> & {
onInput?(evt: JSX.TargetedEvent<HTMLTextAreaElement, Event>): void;
/** Taken as a plain prop rather than through forwardRef, which lives only in preact/compat. */
textareaRef?: RefObject<HTMLTextAreaElement>;
};
export const TextareaAutosize = forwardRef<HTMLTextAreaElement, Props>(({ onInput, value, ...props }, externalRef) => {
const ref = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(externalRef, () => ref.current as HTMLTextAreaElement, []);
export function TextareaAutosize({ onInput, value, textareaRef, ...props }: Props) {
const localRef = useRef<HTMLTextAreaElement>(null);
const ref = textareaRef ?? localRef;
const handleInput: JSX.GenericEventHandler<HTMLTextAreaElement> = (evt) => {
if (!ref.current) {
@@ -32,7 +32,7 @@ export const TextareaAutosize = forwardRef<HTMLTextAreaElement, Props>(({ onInpu
if (ref.current) {
autoResize(ref.current);
}
}, [value]);
}, [value, ref]);
return <textarea {...props} data-testid={props.id} onInput={handleInput} value={value} ref={ref} dir="auto" />;
});
}
@@ -2,8 +2,8 @@ import { h, FunctionComponent, type AriaRole } from 'preact';
import { shallowEqual } from 'store/context';
import { useCallback } from 'preact/hooks';
import clsx from 'clsx';
import { useIntl } from 'react-intl';
import { useIntl } from 'common/intl';
import { Comment as CommentInterface } from 'common/types';
import { getHandleClickProps } from 'common/accessibility';
import { StoreState, useAppDispatch, useAppSelector } from 'store';
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useMemo } from 'preact/compat';
import { useMemo } from 'preact/hooks';
import { useDispatch } from 'store/context';
import { BoundActionCreator, BoundActionCreators } from 'utils/actionBinder';
@@ -1,49 +1,63 @@
import { renderHook } from '@testing-library/preact-hooks';
import { act, renderHook } from '@testing-library/preact';
import { useSessionStorage } from './useSessionState';
describe('useSessionStorage', () => {
// the tests write under the same key, so without this they depend on each other's order
beforeEach(() => {
sessionStorage.clear();
});
it('should return a value and a setter', () => {
const { result } = renderHook(() => useSessionStorage('test', 0));
expect(result.current!).toHaveLength(2);
expect(result.current![0]).toBe(0);
expect(result.current![1]).toBeInstanceOf(Function);
expect(result.current).toHaveLength(2);
expect(result.current[0]).toBe(0);
expect(result.current[1]).toBeInstanceOf(Function);
});
it('should store the value it is given', () => {
const { result } = renderHook(() => useSessionStorage('test', 0));
act(() => result.current[1](5));
expect(result.current[0]).toBe(5);
expect(sessionStorage.getItem('test')).toBe('5');
});
it('should return the initial value', () => {
const { result } = renderHook(() => useSessionStorage('test', 0));
expect(result.current![0]).toBe(0);
expect(result.current[0]).toBe(0);
});
it('should return the stored value', () => {
sessionStorage.setItem('test', JSON.stringify(1));
const { result } = renderHook(() => useSessionStorage('test', 0));
expect(result.current![0]).toBe(1);
expect(result.current[0]).toBe(1);
});
it('should return the stored value if it is falsy', () => {
sessionStorage.setItem('test', JSON.stringify(false));
const { result } = renderHook(() => useSessionStorage('test', 0));
expect(result.current![0]).toBe(false);
expect(result.current[0]).toBe(false);
});
it('should return the initial value if the stored value is not valid JSON', () => {
sessionStorage.setItem('test', 'not valid JSON');
const { result } = renderHook(() => useSessionStorage('test', 0));
expect(result.current![0]).toBe(0);
expect(result.current[0]).toBe(0);
});
it('should return null if the stored value is null', () => {
// @ts-ignore
sessionStorage.setItem('test', null);
const { result } = renderHook(() => useSessionStorage('test', 0));
expect(result.current![0]).toBe(null);
expect(result.current[0]).toBe(null);
});
it('should return the initial value if the stored value is undefined', () => {
// @ts-ignore
sessionStorage.setItem('test', undefined);
const { result } = renderHook(() => useSessionStorage('test', 0));
expect(result.current![0]).toBe(0);
expect(result.current[0]).toBe(0);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { h, render } from 'preact';
import { IntlProvider } from 'react-intl';
import { IntlProvider } from 'common/intl';
import { getLastComments } from 'common/api.getLastComments';
import { BASE_URL } from 'common/constants.config';
import { loadLocale } from 'utils/loadLocale';
+1 -1
View File
@@ -119,7 +119,7 @@
"profile.request-to-delete-data": "Барање за бришење на моите податоци",
"retry": "Обиди се повторно",
"root.pinned-comments": "Пинувани коментари",
"root.powered-by": "Овозможено од <a href='diosfera.codeberg.page'>Диосфера</a>",
"root.powered-by": "Овозможено од <a>Remark42</a>",
"root.show-more": "Прикажи повеќе",
"settings.block": "блокирај",
"settings.block-time": "до {day} во {time}",
+1 -1
View File
@@ -119,7 +119,7 @@
"profile.request-to-delete-data": "ขอให้ลบข้อมูลของฉัน",
"retry": "ลองอีกครั้ง",
"root.pinned-comments": "ความคิดเห็นที่ปักหมุด",
"root.powered-by": "ระบบแสดงความคิดเห็นโดย <a>Remark42</a",
"root.powered-by": "ระบบแสดงความคิดเห็นโดย <a>Remark42</a>",
"root.show-more": "แสดงเพิ่มเติม",
"settings.block": "บล็อก",
"settings.block-time": "จนถึงวันที่ {day} เวลา {time}",
+1 -1
View File
@@ -1,8 +1,8 @@
import { h, render } from 'preact';
import { bindActionCreators } from 'redux';
import { Provider } from 'store/context';
import { IntlProvider } from 'react-intl';
import { IntlProvider } from 'common/intl';
import { loadLocale } from 'utils/loadLocale';
import { parseMessage } from 'utils/post-message';
import { ConnectedRoot } from 'components/root';
+1 -1
View File
@@ -1,8 +1,8 @@
import { h, ComponentChild } from 'preact';
import { IntlProvider } from 'react-intl';
import { render as originalRender } from '@testing-library/preact';
import { Provider } from 'store/context';
import { IntlProvider } from 'common/intl';
import en from 'locales/en.json';
import { mockStore } from '__stubs__/store';
import { StoreState } from 'store';
-1
View File
@@ -1 +0,0 @@
/// <reference types="enzyme-adapter-preact-pure" />
@@ -1,4 +1,4 @@
import { IntlShape, defineMessages } from 'react-intl';
import { IntlShape, defineMessages } from 'common/intl';
import { ApiError } from '../common/types';
export const errorMessages = defineMessages<string | number>({
@@ -1,4 +1,4 @@
/** this is generated file by "npm run translation:generate" **/
/** this is generated file by "pnpm translation:generate" **/
// it is ok that is empty. Default messages from code will be used.
const enMessages = {};
-1
View File
@@ -29,7 +29,6 @@ const config: Config = {
'\\.css': 'identity-obj-proxy',
'\\.svg': '<rootDir>/app/__stubs__/svg.tsx',
},
setupFiles: ['<rootDir>/jest.setup.ts'],
setupFilesAfterEnv: [
'<rootDir>/app/__mocks__/fetch.ts',
'<rootDir>/app/__mocks__/localstorage.ts',
-4
View File
@@ -1,4 +0,0 @@
import { configure } from 'enzyme';
import PreactAdapter from 'enzyme-adapter-preact-pure';
configure({ adapter: new PreactAdapter() });
+1 -9
View File
@@ -17,7 +17,7 @@
"size-check": "cross-env NODE_ENV=production npm run build && size-limit",
"type-check": "tsc -p tsconfig.json --noEmit",
"translation-check": "run-s translation:extract translation:check",
"translation:extract": "formatjs extract --out-file=./extracted-messages/messages.json \"**/*.{ts,tsx}\" --ignore=\"**/*.d.ts\"",
"translation:extract": "formatjs extract --out-file=./extracted-messages/messages.json \"**/*.{ts,tsx}\" --ignore=\"**/*.d.ts\" --ignore=\"**/*.{test,spec}.{ts,tsx}\" --ignore=\"app/tests/**\" --ignore=\"app/__mocks__/**\" --ignore=\"app/__stubs__/**\"",
"translation:generate": "node ./tasks/generateDictionary.js",
"translation:check": "node ./tasks/checkTranslation.js"
},
@@ -32,13 +32,9 @@
"@ungap/custom-elements": "^1.3.0",
"clsx": "^2.1.1",
"core-js": "^3.49.0",
"intersection-observer": "^0.12.2",
"lodash-es": "^4.18.1",
"node-emoji": "^1.11.0",
"preact": "10.29.8",
"react": "npm:@preact/compat@^18.3.2",
"react-dom": "npm:@preact/compat@^18.3.2",
"react-intl": "6.0.5",
"redux": "^4.2.0",
"redux-thunk": "^2.4.1"
},
@@ -58,8 +54,6 @@
"@swc/jest": "^0.2.21",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/preact": "3.2.4",
"@testing-library/preact-hooks": "^1.1.0",
"@types/enzyme": "^3.10.19",
"@types/eslint": "^8.4.5",
"@types/jest": "^28.1.4",
"@types/lodash-es": "^4.17.12",
@@ -79,8 +73,6 @@
"css-minimizer-webpack-plugin": "^4.0.0",
"cssnano": "^5.1.12",
"dotenv": "^17.4.2",
"enzyme": "^3.11.0",
"enzyme-adapter-preact-pure": "^4.1.0",
"eslint": "^8.18.0",
"eslint-config-preact": "^1.3.0",
"eslint-config-prettier": "^8.5.0",
@@ -11,12 +11,107 @@ locales.forEach((locale) => {
keysFromDict.forEach((key) => {
if (!keys.includes(key)) {
errors.push(
`"${key}" key not found in "${locale}" locale dict. Please run "npm run translation:generate" and commit changes.`
`"${key}" key not found in "${locale}" locale dict. Please run "pnpm translation:generate" and commit changes.`
);
}
return null;
});
});
// the loop above catches a locale key absent from the source. this catches the
// other direction, which nothing else can see: a key extracted from somewhere it should not
// be, such as a test fixture, is absent from en.json but would be written into all 24
// catalogues by the next translation:generate, and translators would be asked to translate it
const en = require(getLocalePath({ locale: 'en' }));
keys.forEach((key) => {
if (!Object.keys(en).includes(key)) {
errors.push(
`"${key}" was extracted from the source but is missing from the "en" locale dict. ` +
`If it is a new message, run "translation:generate" and commit the catalogues. ` +
`If it comes from a test or a fixture, exclude that file from "translation:extract".`
);
}
});
// a translation's markup and placeholders have to match the contract of the English string
// it translates. neither the extractor nor the key comparison above looks at a value, so
// without this a broken tag or an invented placeholder ships and the widget silently renders
// that message in English instead of the translation
const PLACEHOLDER = /\{\s*(\w+)\s*\}/g;
function placeholdersIn(message) {
return new Set(Array.from(message.matchAll(PLACEHOLDER), (match) => match[1]));
}
function tagsIn(message) {
return new Set(Array.from(message.matchAll(/<(\w+)>/g), (match) => match[1]));
}
// mirrors app/common/intl.tsx: a self-closing tag is text, and only `</` or `<` opening a
// tag name is markup that has to resolve. testing for any angle bracket instead would
// reject ordinary text such as "under < 10"
const SELF_CLOSING = /<[a-zA-Z][\w-]*\s*\/>/g;
const TAG_START = /<\/|<[a-zA-Z]/;
/**
* True when anything tag-shaped survives once the pairs the English string uses are taken
* out. The pair contents are checked too, since stripping a pair removes whatever was
* nested inside it, and the widget rejects a nested tag.
*/
function hasBrokenMarkup(message, tags) {
const chunks = [];
const withoutPairs = tags.reduce(
(text, tag) =>
text.replace(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'), (_match, chunk) => {
chunks.push(chunk);
return '';
}),
message
);
return (
TAG_START.test(withoutPairs.replace(SELF_CLOSING, '')) ||
chunks.some((chunk) => TAG_START.test(chunk.replace(SELF_CLOSING, '')))
);
}
locales.forEach((locale) => {
if (locale === 'en') {
return;
}
const dict = require(getLocalePath({ locale }));
Object.keys(dict).forEach((key) => {
const source = en[key];
const value = dict[key];
if (typeof source !== 'string' || typeof value !== 'string') {
return;
}
const tags = Array.from(tagsIn(source));
if (hasBrokenMarkup(value, tags)) {
errors.push(
`"${key}" in "${locale}" has markup the widget cannot resolve: ${JSON.stringify(value)}. ` +
`Tags must be well-formed pairs of the same names the English string uses ` +
`(${tags.length ? tags.map((tag) => `<${tag}>`).join(', ') : 'none'}), with no attributes. ` +
`A translation may leave a tag out entirely. As written, this message renders in English.`
);
}
const unknown = Array.from(placeholdersIn(value)).filter((name) => !placeholdersIn(source).has(name));
if (unknown.length) {
errors.push(
`"${key}" in "${locale}" uses ${unknown.map((name) => `{${name}}`).join(', ')}, which the ` +
`English string does not provide. As written, this message renders in English.`
);
}
});
});
if (errors.length) {
// eslint-disable-next-line no-console
console.error(errors.join(`\n`));
@@ -1,5 +1,5 @@
function renderLoadLocale(locales) {
return `/** this is generated file by "npm run translation:generate" **/
return `/** this is generated file by "pnpm translation:generate" **/
// it is ok that is empty. Default messages from code will be used.
const enMessages = {};
-2
View File
@@ -16,8 +16,6 @@
"skipLibCheck": true,
"sourceMap": true,
"paths": {
"react": ["../node_modules/preact/compat"],
"react-dom": ["../node_modules/preact/compat"],
"preact": ["../node_modules/preact"],
"preact/*": ["../node_modules/preact/*"]
}
-3
View File
@@ -55,9 +55,6 @@ function getLocalIdent(loaderContext, _, localName, options) {
const exclude = [
'@github/markdown-toolbar-element',
'@github/text-expander-element',
'react-intl',
'intl-messageformat',
'intl-messageformat-parser',
].map((m) => path.resolve(__dirname, 'node_modules', m));
const htmlMinifyOptions = {
+1 -2
View File
@@ -85,8 +85,7 @@
"undici@>=7.0.0 <8.0.0": ">=7.29.0 <8.0.0",
"body-parser@<1.20.6": ">=1.20.6 <2.0.0",
"preact": "10.29.8",
"@types/minimatch": "5.1.2",
"cheerio": "1.0.0-rc.12"
"@types/minimatch": "5.1.2"
}
}
}
-441
View File
@@ -71,7 +71,6 @@ overrides:
body-parser@<1.20.6: '>=1.20.6 <2.0.0'
preact: 10.29.8
'@types/minimatch': 5.1.2
cheerio: 1.0.0-rc.12
importers:
@@ -101,9 +100,6 @@ importers:
core-js:
specifier: ^3.49.0
version: 3.49.0
intersection-observer:
specifier: ^0.12.2
version: 0.12.2
lodash-es:
specifier: ^4.18.1
version: 4.18.1
@@ -113,15 +109,6 @@ importers:
preact:
specifier: 10.29.8
version: 10.29.8
react:
specifier: npm:@preact/compat@^18.3.2
version: '@preact/compat@18.3.2(preact@10.29.8)'
react-dom:
specifier: npm:@preact/compat@^18.3.2
version: '@preact/compat@18.3.2(preact@10.29.8)'
react-intl:
specifier: 6.0.5
version: 6.0.5(@preact/compat@18.3.2(preact@10.29.8))(typescript@5.9.3)
redux:
specifier: ^4.2.0
version: 4.2.1
@@ -174,12 +161,6 @@ importers:
'@testing-library/preact':
specifier: 3.2.4
version: 3.2.4(preact@10.29.8)
'@testing-library/preact-hooks':
specifier: ^1.1.0
version: 1.1.0(@testing-library/preact@3.2.4(preact@10.29.8))(preact@10.29.8)
'@types/enzyme':
specifier: ^3.10.19
version: 3.10.19
'@types/eslint':
specifier: ^8.4.5
version: 8.56.12
@@ -237,12 +218,6 @@ importers:
dotenv:
specifier: ^17.4.2
version: 17.4.2
enzyme:
specifier: ^3.11.0
version: 3.11.0
enzyme-adapter-preact-pure:
specifier: ^4.1.0
version: 4.1.0(enzyme@3.11.0)(preact@10.29.8)
eslint:
specifier: ^8.18.0
version: 8.57.1
@@ -1238,35 +1213,6 @@ packages:
'@vue/compiler-sfc':
optional: true
'@formatjs/ecma402-abstract@1.11.8':
resolution: {integrity: sha512-fgLqyWlwmTEuqV/TSLEL/t9JOmHNLFvCdgzXB0jc2w+WOItPCOJ1T0eyN6fQBQKRPfSqqNlu+kWj7ijcOVTVVQ==}
'@formatjs/fast-memoize@1.2.4':
resolution: {integrity: sha512-9ARYoLR8AEzXvj2nYrOVHY/h1dDMDWGTnKDLXSISF1uoPakSmfcZuSqjiqZX2wRkEUimPxdwTu/agyozBtZRHA==}
'@formatjs/icu-messageformat-parser@2.1.4':
resolution: {integrity: sha512-3PqMvKWV1oyok0BuiXUAHIaotdhdTJw6OICqCZbfUgKT+ZRwRWO4IlCgvXJeCITaKS5p+PY0XXKjf/vUyIpWjQ==}
'@formatjs/icu-skeleton-parser@1.3.10':
resolution: {integrity: sha512-kXJmtLDqFF5aLTf8IxdJXnhrIX1Qb4Qp3a9jqRecGDYfzOa9hMhi9U0nKyhrJJ4cXxBzptcgb+LWkyeHL6nlBQ==}
'@formatjs/intl-displaynames@6.0.3':
resolution: {integrity: sha512-Mxh6W1VOlmiEvO/QPBrBQHlXrIn5VxjJWyyEI0V7ZHNGl0ee8AjSlq7vIJG8GodRJqGUuutF6N3OB/6qFv0YWg==}
'@formatjs/intl-listformat@7.0.3':
resolution: {integrity: sha512-ampNLRGZl/08epHa3i5sRmcHGLneC6JrknexbbgnexYFNSmJ6AbL/dCzgrQzw2Efl+5AZK7UbNFxcDYY3RePvw==}
'@formatjs/intl-localematcher@0.2.28':
resolution: {integrity: sha512-FLsc6Gifs1np/8HnCn/7Q+lHMmenrD5fuDhRT82yj0gi9O19kfaFwjQUw1gZsyILuRyT93GuzdifHj7TKRhBcw==}
'@formatjs/intl@2.3.1':
resolution: {integrity: sha512-f06qZ/ukpeN24gc01qFjh3P+r3FU/ikY4yG+fDJu6dPNvpUQzDy98lYogA1dr6ig2UtrnoEk3xncyFPL1e9cZw==}
peerDependencies:
typescript: ^4.5
peerDependenciesMeta:
typescript:
optional: true
'@github/combobox-nav@2.3.1':
resolution: {integrity: sha512-gwxPzLw8XKecy1nP63i9lOBritS3bWmxl02UX6G0TwMQZbMem1BCS1tEZgYd3mkrkiDrUMWaX+DbFCuDFo3K+A==}
@@ -1604,11 +1550,6 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@preact/compat@18.3.2':
resolution: {integrity: sha512-5vSl55K5yLMvocT7PBKxDOHGgYPjMrKQqqr6roSNjIXcJOtSgDDMjpiCAF3s7klRdmGrN75b/Przmjw8gmlg/w==}
peerDependencies:
preact: 10.29.8
'@prefresh/babel-plugin@0.4.4':
resolution: {integrity: sha512-/EvgIFMDL+nd20WNvMO0JQnzIl1EJPgmSaSYrZUww7A+aSdKsi37aL07TljrZR1cBMuzFxcr4xvqsUQLFJEukw==}
@@ -1758,12 +1699,6 @@ packages:
resolution: {integrity: sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==}
engines: {node: '>=8', npm: '>=6', yarn: '>=1'}
'@testing-library/preact-hooks@1.1.0':
resolution: {integrity: sha512-+JIor+NsOHkK3oIrwMDGKGHXTN0JJi462dBJlj4FNbGaDPTlctE6eu2ranWQirh7/FJMkWfzQCP+tk7jmY8ZrQ==}
peerDependencies:
'@testing-library/preact': ^2.0.0
preact: 10.29.8
'@testing-library/preact@3.2.4':
resolution: {integrity: sha512-F+kJ243LP6VmEK1M809unzTE/ijg+bsMNuiRN0JEDIJBELKKDNhdgC/WrUSZ7klwJvtlO3wQZ9ix+jhObG07Fg==}
engines: {node: '>= 12'}
@@ -1807,18 +1742,12 @@ packages:
'@types/bonjour@3.5.13':
resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==}
'@types/cheerio@0.22.35':
resolution: {integrity: sha512-yD57BchKRvTV+JD53UZ6PD8KWY5g5rvvMLRnZR3EQBCZXiDT/HR+pKpMzFGlWNhFrXlo7VPZXtKvIEwZkAWOIA==}
'@types/connect-history-api-fallback@1.5.4':
resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/enzyme@3.10.19':
resolution: {integrity: sha512-kIfCo6/DdpgCHgmrLgPTugjzbZ46BUK8S2IP0kYo8+62LD2l1k8mSVsc+zQYNTdjDRoh2E9Spxu6F1NnEiW38Q==}
'@types/eslint@8.56.12':
resolution: {integrity: sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==}
@@ -1843,11 +1772,6 @@ packages:
'@types/graceful-fs@4.1.9':
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
'@types/hoist-non-react-statics@3.3.7':
resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==}
peerDependencies:
'@types/react': '*'
'@types/html-minifier-terser@6.1.0':
resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==}
@@ -1911,30 +1835,18 @@ packages:
'@types/prettier@2.7.3':
resolution: {integrity: sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/react@16.14.70':
resolution: {integrity: sha512-DM5Q7rSx9G6QYcVvMgxvEurL5P06OxcDNUXrLxlpBzG4ccUewcBCmsztYbxJBobzO8RIwwmjoaD5OsKqdHDuYQ==}
'@types/react@18.3.31':
resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==}
'@types/redux-mock-store@1.5.0':
resolution: {integrity: sha512-jcscBazm6j05Hs6xYCca6psTUBbFT2wqMxT7wZEHAYFxHB/I8jYk7d5msrHUlDiSL02HdTqTmkK2oIV8i3C8DA==}
'@types/retry@0.12.2':
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
'@types/scheduler@0.16.8':
resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==}
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
@@ -2333,10 +2245,6 @@ packages:
resolution: {integrity: sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==}
engines: {node: '>=0.10.0'}
array.prototype.filter@1.0.4:
resolution: {integrity: sha512-r+mCJ7zXgXElgR4IRC+fkvNCeoaavWBs6EdCso5Tbcf+iEMKzBU/His60lt34WEZ9vlb8wDkZvQGcVI5GwkfoQ==}
engines: {node: '>= 0.4'}
array.prototype.findlast@1.2.5:
resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
engines: {node: '>= 0.4'}
@@ -2605,13 +2513,6 @@ packages:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
cheerio-select@2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
cheerio@1.0.0-rc.12:
resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==}
engines: {node: '>= 6'}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@@ -2927,9 +2828,6 @@ packages:
resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==}
engines: {node: '>=8'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -3070,9 +2968,6 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
discontinuous-range@1.0.0:
resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
dns-packet@5.6.1:
resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==}
engines: {node: '>=6'}
@@ -3174,27 +3069,11 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
envinfo@7.21.0:
resolution: {integrity: sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==}
engines: {node: '>=4'}
hasBin: true
enzyme-adapter-preact-pure@4.1.0:
resolution: {integrity: sha512-SifzvBGf1qUEs0FfCAOiKgDppb1wg/R+rmX8D7jFVpQ/Q2L3/xuyc1V575zoi8QAhIBDUB/8QUWvI4KZc50trw==}
peerDependencies:
enzyme: ^3.11.0
preact: 10.29.8
enzyme-shallow-equal@1.0.7:
resolution: {integrity: sha512-/um0GFqUXnpM9SvKtje+9Tjoz3f1fpBC3eXRFrNs8kpYn69JljciYP7KZTqM/YQbUY9KUjvKB4jo/q+L6WGGvg==}
enzyme@3.11.0:
resolution: {integrity: sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==}
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@@ -3206,9 +3085,6 @@ packages:
resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
engines: {node: '>= 0.4'}
es-array-method-boxes-properly@1.0.0:
resolution: {integrity: sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@@ -3748,10 +3624,6 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
has@1.0.4:
resolution: {integrity: sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==}
engines: {node: '>= 0.4.0'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
@@ -3760,9 +3632,6 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
@@ -3773,10 +3642,6 @@ packages:
hpack.js@2.1.6:
resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==}
html-element-map@1.4.0:
resolution: {integrity: sha512-jiTQtpaVnCcT1KDghMcmvbB5Q1AAWyBsGNuJZiHOWwN5GIVZGKqCWj9ddOFxLLz8ELYL2dwv2TaeS4dMdc/Pkw==}
engines: {node: '>= 0.4'}
html-encoding-sniffer@3.0.0:
resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==}
engines: {node: '>=12'}
@@ -3925,13 +3790,6 @@ packages:
resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==}
engines: {node: '>= 0.10'}
intersection-observer@0.12.2:
resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==}
deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.
intl-messageformat@10.1.1:
resolution: {integrity: sha512-FeJne2oooYW6shLPbrqyjRX6hTELVrQ90Dn88z7NomLk/xZBCLxLPAkgaYaTQJBRBV78nZ933d8APHHkTQrD9Q==}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@@ -4104,9 +3962,6 @@ packages:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'}
is-subset@0.1.1:
resolution: {integrity: sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==}
is-symbol@1.1.1:
resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
engines: {node: '>= 0.4'}
@@ -4472,16 +4327,6 @@ packages:
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.escape@4.0.1:
resolution: {integrity: sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==}
lodash.flattendeep@4.4.0:
resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==}
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
@@ -4703,9 +4548,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
moo@0.5.3:
resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -4742,10 +4584,6 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
nearley@2.20.1:
resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
hasBin: true
negotiator@0.6.3:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
@@ -4947,15 +4785,9 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse5-htmlparser2-tree-adapter@7.1.0:
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
parse5@6.0.1:
resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -5000,9 +4832,6 @@ packages:
resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==}
engines: {node: '>=18'}
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -5534,16 +5363,6 @@ packages:
resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==}
engines: {node: '>=8'}
raf@3.4.1:
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
railroad-diagrams@1.0.0:
resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
randexp@0.4.6:
resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
engines: {node: '>=0.12'}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -5556,15 +5375,6 @@ packages:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
engines: {node: '>= 0.8'}
react-intl@6.0.5:
resolution: {integrity: sha512-nDZ3BosuE8WdovcGxsrjj1aIgJZklSL5aORs5oah+5tLQTzUdOEstzJEYQPM+sxl1dkDOu7RCuw0z9oI9ENf9g==}
peerDependencies:
react: ^16.6.0 || 17 || 18
typescript: ^4.5
peerDependenciesMeta:
typescript:
optional: true
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -5692,10 +5502,6 @@ packages:
resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
ret@0.1.15:
resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
engines: {node: '>=0.12'}
retry@0.13.1:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
@@ -5717,9 +5523,6 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rst-selector-parser@2.2.3:
resolution: {integrity: sha512-nDG1rZeP6oFTLN6yNDV/uiAvs1+FS/KlrEwh7+y7dpuApDBy6bI2HTBcc0/V8lv9OTqfyD34eF7au2pm8aBbhA==}
run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
@@ -6219,9 +6022,6 @@ packages:
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.4.0:
resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -7632,54 +7432,6 @@ snapshots:
'@formatjs/cli@5.1.13': {}
'@formatjs/ecma402-abstract@1.11.8':
dependencies:
'@formatjs/intl-localematcher': 0.2.28
tslib: 2.4.0
'@formatjs/fast-memoize@1.2.4':
dependencies:
tslib: 2.4.0
'@formatjs/icu-messageformat-parser@2.1.4':
dependencies:
'@formatjs/ecma402-abstract': 1.11.8
'@formatjs/icu-skeleton-parser': 1.3.10
tslib: 2.4.0
'@formatjs/icu-skeleton-parser@1.3.10':
dependencies:
'@formatjs/ecma402-abstract': 1.11.8
tslib: 2.4.0
'@formatjs/intl-displaynames@6.0.3':
dependencies:
'@formatjs/ecma402-abstract': 1.11.8
'@formatjs/intl-localematcher': 0.2.28
tslib: 2.4.0
'@formatjs/intl-listformat@7.0.3':
dependencies:
'@formatjs/ecma402-abstract': 1.11.8
'@formatjs/intl-localematcher': 0.2.28
tslib: 2.4.0
'@formatjs/intl-localematcher@0.2.28':
dependencies:
tslib: 2.4.0
'@formatjs/intl@2.3.1(typescript@5.9.3)':
dependencies:
'@formatjs/ecma402-abstract': 1.11.8
'@formatjs/fast-memoize': 1.2.4
'@formatjs/icu-messageformat-parser': 2.1.4
'@formatjs/intl-displaynames': 6.0.3
'@formatjs/intl-listformat': 7.0.3
intl-messageformat: 10.1.1
tslib: 2.4.0
optionalDependencies:
typescript: 5.9.3
'@github/combobox-nav@2.3.1': {}
'@github/markdown-toolbar-element@2.2.3': {}
@@ -8192,10 +7944,6 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@preact/compat@18.3.2(preact@10.29.8)':
dependencies:
preact: 10.29.8
'@prefresh/babel-plugin@0.4.4': {}
'@prefresh/core@1.5.10(preact@10.29.8)':
@@ -8324,11 +8072,6 @@ snapshots:
lodash: 4.18.1
redent: 3.0.0
'@testing-library/preact-hooks@1.1.0(@testing-library/preact@3.2.4(preact@10.29.8))(preact@10.29.8)':
dependencies:
'@testing-library/preact': 3.2.4(preact@10.29.8)
preact: 10.29.8
'@testing-library/preact@3.2.4(preact@10.29.8)':
dependencies:
'@testing-library/dom': 8.20.1
@@ -8376,10 +8119,6 @@ snapshots:
dependencies:
'@types/node': 26.0.1
'@types/cheerio@0.22.35':
dependencies:
'@types/node': 18.19.130
'@types/connect-history-api-fallback@1.5.4':
dependencies:
'@types/express-serve-static-core': 5.1.3
@@ -8389,11 +8128,6 @@ snapshots:
dependencies:
'@types/node': 26.0.1
'@types/enzyme@3.10.19':
dependencies:
'@types/cheerio': 0.22.35
'@types/react': 16.14.70
'@types/eslint@8.56.12':
dependencies:
'@types/estree': 1.0.9
@@ -8437,11 +8171,6 @@ snapshots:
dependencies:
'@types/node': 18.19.130
'@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31)':
dependencies:
'@types/react': 18.3.31
hoist-non-react-statics: 3.3.2
'@types/html-minifier-terser@6.1.0': {}
'@types/http-errors@2.0.5': {}
@@ -8501,31 +8230,16 @@ snapshots:
'@types/prettier@2.7.3': {}
'@types/prop-types@15.7.15': {}
'@types/qs@6.15.1': {}
'@types/range-parser@1.2.7': {}
'@types/react@16.14.70':
dependencies:
'@types/prop-types': 15.7.15
'@types/scheduler': 0.16.8
csstype: 3.2.3
'@types/react@18.3.31':
dependencies:
'@types/prop-types': 15.7.15
csstype: 3.2.3
'@types/redux-mock-store@1.5.0':
dependencies:
redux: 4.2.1
'@types/retry@0.12.2': {}
'@types/scheduler@0.16.8': {}
'@types/semver@7.7.1': {}
'@types/send@0.17.6':
@@ -8997,15 +8711,6 @@ snapshots:
array-uniq@1.0.3: {}
array.prototype.filter@1.0.4:
dependencies:
call-bind: 1.0.9
define-properties: 1.2.1
es-abstract: 1.24.2
es-array-method-boxes-properly: 1.0.0
es-object-atoms: 1.1.2
is-string: 1.1.1
array.prototype.findlast@1.2.5:
dependencies:
call-bind: 1.0.9
@@ -9364,25 +9069,6 @@ snapshots:
char-regex@1.0.2: {}
cheerio-select@2.1.0:
dependencies:
boolbase: 1.0.0
css-select: 5.2.2
css-what: 6.2.2
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
cheerio@1.0.0-rc.12:
dependencies:
cheerio-select: 2.1.0
dom-serializer: 2.0.0
domhandler: 5.0.3
domutils: 3.2.2
htmlparser2: 8.0.2
parse5: 7.3.0
parse5-htmlparser2-tree-adapter: 7.1.0
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
@@ -9698,8 +9384,6 @@ snapshots:
dependencies:
cssom: 0.3.8
csstype@3.2.3: {}
damerau-levenshtein@1.0.8: {}
data-urls@3.0.2:
@@ -9831,8 +9515,6 @@ snapshots:
dependencies:
path-type: 4.0.0
discontinuous-range@1.0.0: {}
dns-packet@5.6.1:
dependencies:
'@leichtgewicht/ip-codec': 2.0.5
@@ -9931,45 +9613,8 @@ snapshots:
entities@4.5.0: {}
entities@6.0.1: {}
envinfo@7.21.0: {}
enzyme-adapter-preact-pure@4.1.0(enzyme@3.11.0)(preact@10.29.8):
dependencies:
enzyme: 3.11.0
preact: 10.29.8
enzyme-shallow-equal@1.0.7:
dependencies:
hasown: 2.0.4
object-is: 1.1.6
enzyme@3.11.0:
dependencies:
array.prototype.flat: 1.3.3
cheerio: 1.0.0-rc.12
enzyme-shallow-equal: 1.0.7
function.prototype.name: 1.2.0
has: 1.0.4
html-element-map: 1.4.0
is-boolean-object: 1.2.2
is-callable: 1.2.7
is-number-object: 1.1.1
is-regex: 1.2.1
is-string: 1.1.1
is-subset: 0.1.1
lodash.escape: 4.0.1
lodash.isequal: 4.5.0
object-inspect: 1.13.4
object-is: 1.1.6
object.assign: 4.1.7
object.entries: 1.1.9
object.values: 1.2.1
raf: 3.4.1
rst-selector-parser: 2.2.3
string.prototype.trim: 1.2.11
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
@@ -10038,8 +9683,6 @@ snapshots:
unbox-primitive: 1.1.0
which-typed-array: 1.1.22
es-array-method-boxes-properly@1.0.0: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
@@ -10795,18 +10438,12 @@ snapshots:
dependencies:
has-symbols: 1.1.0
has@1.0.4: {}
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
he@1.2.0: {}
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
hosted-git-info@2.8.9: {}
hosted-git-info@4.1.0:
@@ -10820,11 +10457,6 @@ snapshots:
readable-stream: 2.3.8
wbuf: 1.7.3
html-element-map@1.4.0:
dependencies:
array.prototype.filter: 1.0.4
es-errors: 1.3.0
html-encoding-sniffer@3.0.0:
dependencies:
whatwg-encoding: 2.0.0
@@ -10977,15 +10609,6 @@ snapshots:
interpret@2.2.0: {}
intersection-observer@0.12.2: {}
intl-messageformat@10.1.1:
dependencies:
'@formatjs/ecma402-abstract': 1.11.8
'@formatjs/fast-memoize': 1.2.4
'@formatjs/icu-messageformat-parser': 2.1.4
tslib: 2.4.0
ipaddr.js@1.9.1: {}
ipaddr.js@2.4.0: {}
@@ -11134,8 +10757,6 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-subset@0.1.1: {}
is-symbol@1.1.1:
dependencies:
call-bound: 1.0.4
@@ -11731,12 +11352,6 @@ snapshots:
lodash.debounce@4.0.8: {}
lodash.escape@4.0.1: {}
lodash.flattendeep@4.4.0: {}
lodash.isequal@4.5.0: {}
lodash.isplainobject@4.0.6: {}
lodash.memoize@4.1.2: {}
@@ -11914,8 +11529,6 @@ snapshots:
mkdirp@1.0.4: {}
moo@0.5.3: {}
mrmime@2.0.1: {}
ms@2.0.0: {}
@@ -11941,13 +11554,6 @@ snapshots:
natural-compare@1.4.0: {}
nearley@2.20.1:
dependencies:
commander: 2.20.3
moo: 0.5.3
railroad-diagrams: 1.0.0
randexp: 0.4.6
negotiator@0.6.3: {}
negotiator@0.6.4: {}
@@ -12173,17 +11779,8 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse5-htmlparser2-tree-adapter@7.1.0:
dependencies:
domhandler: 5.0.3
parse5: 7.3.0
parse5@6.0.1: {}
parse5@7.3.0:
dependencies:
entities: 6.0.1
parseurl@1.3.3: {}
pascal-case@3.1.2:
@@ -12213,8 +11810,6 @@ snapshots:
path-type@6.0.0: {}
performance-now@2.1.0: {}
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -12727,17 +12322,6 @@ snapshots:
quick-lru@4.0.1: {}
raf@3.4.1:
dependencies:
performance-now: 2.1.0
railroad-diagrams@1.0.0: {}
randexp@0.4.6:
dependencies:
discontinuous-range: 1.0.0
ret: 0.1.15
range-parser@1.2.1: {}
range-parser@1.3.0: {}
@@ -12749,22 +12333,6 @@ snapshots:
iconv-lite: 0.4.24
unpipe: 1.0.0
react-intl@6.0.5(@preact/compat@18.3.2(preact@10.29.8))(typescript@5.9.3):
dependencies:
'@formatjs/ecma402-abstract': 1.11.8
'@formatjs/icu-messageformat-parser': 2.1.4
'@formatjs/intl': 2.3.1(typescript@5.9.3)
'@formatjs/intl-displaynames': 6.0.3
'@formatjs/intl-listformat': 7.0.3
'@types/hoist-non-react-statics': 3.3.7(@types/react@18.3.31)
'@types/react': 18.3.31
hoist-non-react-statics: 3.3.2
intl-messageformat: 10.1.1
react: '@preact/compat@18.3.2(preact@10.29.8)'
tslib: 2.4.0
optionalDependencies:
typescript: 5.9.3
react-is@16.13.1: {}
react-is@17.0.2: {}
@@ -12920,8 +12488,6 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
ret@0.1.15: {}
retry@0.13.1: {}
reusify@1.1.0: {}
@@ -12936,11 +12502,6 @@ snapshots:
dependencies:
glob: 7.2.3
rst-selector-parser@2.2.3:
dependencies:
lodash.flattendeep: 4.4.0
nearley: 2.20.1
run-applescript@7.1.0: {}
run-parallel@1.2.0:
@@ -13581,8 +13142,6 @@ snapshots:
tslib@1.14.1: {}
tslib@2.4.0: {}
tslib@2.8.1: {}
tsutils@3.21.0(typescript@5.9.3):
+4 -3
View File
@@ -8,7 +8,8 @@ Frontend for Remark42 is built with [Preact](https://preactjs.com) and [Redux](h
::: note 💡
We highly recommend checking out Preact [documentation](https://preactjs.com/guide/v10/getting-started).
TLDR: Preact replicates React API and compatible with its libraries.
React libraries are not usable here: the store bindings and the i18n helpers are small local
modules under `app/`.
:::
In order to inject Remark42 widgets into websites we use `iframe` and `postMessage` for communication between a site and the widget.
@@ -111,10 +112,10 @@ Run `pnpm build` inside `./frontend`, and result files will be saved in `./front
## Testing
- Project uses [Jest](https://jestjs.io) as test framework
- [Testing Library](https://testing-library.com) is used as UI test utilities (there are still tests with Enzyme, but we are in process of migration)
- [Testing Library](https://testing-library.com) is used for UI tests
- Jest checks files that match regex `\.(test|spec)\.ts(x?)$`, i.e., `comment.test.tsx`, `comment.spec.ts`
- Tests are running on push attempt
- Example tests can be found in `./app/components/auth/auth.spec.ts`, `./app/store/user/reducers.test.ts`
- Example tests can be found in `./app/components/auth/auth.spec.tsx`, `./app/store/user/reducers.test.ts`
## Notes
@@ -16,6 +16,23 @@ directory with `.json` extension and content like following:
}
```
::: note 🚨
Translations support `{name}` placeholders and paired tags such as `<a>text</a>`. ICU plural,
select and typed-argument syntax is not supported: a message using one either falls back to
English or shows the raw syntax on the page, depending on the message. Apostrophe quoting is not
supported either, so `''` stays as two apostrophes.
You may drop a tag from the English string if your language reads better without the link. If
you keep it, it has to stay paired and keep the same name, and every placeholder has to be one
the English string already uses. A tag that is broken, stray, nested or unknown, or a placeholder
the widget does not supply, makes that whole message fall back to English.
CI checks every value's tags and placeholders against the English string it translates, and
renders the two messages that carry a link. It cannot tell that an ICU form is unsupported,
since that is ordinary text to it, so open your translation in the interface before sending
it.
:::
### Add a new translation
We truly appreciate people spending time contributing their translations to remark42. Please go through the steps
@@ -31,9 +48,9 @@ below to have your translation available to all remark42 users and included in t
```
1. Add a new locale with a [two-letter code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) of the language you want to do the translation into to list in [frontend/apps/remark42/tasks/supportedLocales.json](https://github.com/umputun/remark42/blob/master/frontend/apps/remark42/tasks/supportedLocales.json)
1. Run `npm install` in the `frontend` folder
1. Run `npm run translation:extract` in the `frontend` folder
1. Run `npm run translation:generate` in the `frontend` folder
1. Run `pnpm i` in the `frontend` folder
1. Run `pnpm translation:extract` in the `frontend/apps/remark42` folder
1. Run `pnpm translation:generate` in the `frontend/apps/remark42` folder
1. Translate all values in the newly created JSON file in
[frontend/apps/remark42/app/locales/](https://github.com/umputun/remark42/tree/master/frontend/apps/remark42/app/locales)
1. Commit all changes above in your fork