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:
+53
-7
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
+93
-128
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+16
-17
@@ -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
-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';
|
||||
|
||||
+1
-1
@@ -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,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';
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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,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,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 +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 = {};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { configure } from 'enzyme';
|
||||
import PreactAdapter from 'enzyme-adapter-preact-pure';
|
||||
|
||||
configure({ adapter: new PreactAdapter() });
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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/*"]
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-441
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user