@unbound-app/api 0.11.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ambient/image-modules.d.ts +58 -0
- package/package.json +27 -4
- package/src/_internal.d.ts +2775 -139
- package/src/{utils.d.ts → _types.d.ts} +10 -0
- package/src/assets.d.ts +64 -26
- package/src/builtins.d.ts +17 -0
- package/src/commands.d.ts +101 -25
- package/src/dialogs.d.ts +90 -25
- package/src/flux.d.ts +317 -0
- package/src/fonts.d.ts +51 -0
- package/src/fs.d.ts +15 -24
- package/src/global.d.ts +173 -11
- package/src/i18n.d.ts +33 -31
- package/src/iconPacks.d.ts +43 -0
- package/src/index.d.ts +14 -0
- package/src/marketplace.d.ts +50 -0
- package/src/metro/api.d.ts +8 -0
- package/src/metro/common.d.ts +34 -5
- package/src/metro/components.d.ts +34 -25
- package/src/metro/filters.d.ts +31 -16
- package/src/metro/index.d.ts +26079 -83
- package/src/metro/stores.d.ts +5 -0
- package/src/native.d.ts +77 -5
- package/src/patcher.d.ts +214 -57
- package/src/plugins.d.ts +55 -0
- package/src/settings.d.ts +8 -16
- package/src/shims.d.ts +5 -0
- package/src/storage.d.ts +80 -224
- package/src/sync.d.ts +51 -0
- package/src/themes.d.ts +43 -0
- package/src/toasts.d.ts +62 -9
- package/src/ui.d.ts +650 -0
- package/src/updates.d.ts +25 -0
- package/src/utils/index.d.ts +53 -0
- package/src/utils/react/index.d.ts +82 -0
- package/testing/fake-api.ts +256 -0
- package/testing/fake-components.ts +183 -0
- package/testing/fake-feedback.ts +258 -0
- package/testing/fake-fs.ts +54 -0
- package/testing/fake-java.ts +513 -0
- package/testing/fake-locales.ts +85 -0
- package/testing/fake-metro.ts +346 -0
- package/testing/fake-native-bridge.ts +98 -0
- package/testing/fake-native.ts +211 -0
- package/testing/fake-objc.ts +388 -0
- package/testing/fake-plugins.ts +176 -0
- package/testing/fake-react-native-modules.ts +362 -0
- package/testing/fake-react-native.ts +180 -0
- package/testing/fake-react.ts +54 -0
- package/testing/fake-settings-panel.ts +173 -0
- package/testing/fake-settings.ts +428 -0
- package/testing/fake-timers.ts +74 -0
- package/testing/fake-utils.ts +216 -0
- package/testing/icu-format.ts +132 -0
- package/testing/icu-parse.ts +277 -0
- package/testing/index.ts +42 -0
- package/testing/install-addon-globals.ts +260 -0
- package/testing/listener-registry.ts +39 -0
- package/testing/memory-storage.ts +149 -0
- package/testing/plugin-files.ts +155 -0
- package/testing/plugin-lifecycle.ts +149 -0
- package/testing/plugin-types.ts +34 -0
- package/testing/preset.ts +263 -0
- package/testing/react-patcher.ts +154 -0
- package/testing/react-value.ts +3 -0
- package/testing/recorders.ts +183 -0
- package/testing/recording-patcher.ts +173 -0
- package/testing/render.ts +84 -0
- package/testing/session.ts +77 -0
- package/testing/test-node.ts +163 -0
- package/testing/test-renderer.ts +753 -0
- package/testing/unfrozen-elements.ts +49 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { TreeNode, TreePredicate, TreeSearchOptions } from '../src/utils/index';
|
|
4
|
+
import type { PredicateResult } from '../src/_types';
|
|
5
|
+
|
|
6
|
+
// These mirror the client's `utils` line for line: the testing kit ships without the client's private packages.
|
|
7
|
+
|
|
8
|
+
/** What `findComponent` and `filterComponents` match by: a component name, or a predicate on each element. */
|
|
9
|
+
type ComponentMatch = string | ((element: TreeNode) => PredicateResult);
|
|
10
|
+
|
|
11
|
+
const ELEMENT_TYPES = new Set([
|
|
12
|
+
Symbol.for('react.transitional.element'),
|
|
13
|
+
Symbol.for('react.element'),
|
|
14
|
+
]);
|
|
15
|
+
const MEMO = Symbol.for('react.memo');
|
|
16
|
+
const FORWARD_REF = Symbol.for('react.forward_ref');
|
|
17
|
+
// The keys a React search descends into; `return` and `alternate` point back up, so it only goes down.
|
|
18
|
+
const REACT_KEYS = [
|
|
19
|
+
'props',
|
|
20
|
+
'children',
|
|
21
|
+
'type',
|
|
22
|
+
'child',
|
|
23
|
+
'sibling',
|
|
24
|
+
'memoizedProps',
|
|
25
|
+
'memoizedState',
|
|
26
|
+
'stateNode',
|
|
27
|
+
];
|
|
28
|
+
// Elements nest through their props, so only `props` and `children` lead to more elements.
|
|
29
|
+
const ELEMENT_OPTIONS: TreeSearchOptions = { walkable: ['props', 'children'] };
|
|
30
|
+
|
|
31
|
+
function walkTree(
|
|
32
|
+
root: TreeNode,
|
|
33
|
+
predicate: TreePredicate,
|
|
34
|
+
options: TreeSearchOptions,
|
|
35
|
+
all: boolean,
|
|
36
|
+
): TreeNode[] {
|
|
37
|
+
const { walkable, ignore = [], maxDepth = 100 } = options;
|
|
38
|
+
const seen = new WeakSet<object>();
|
|
39
|
+
const found: TreeNode[] = [];
|
|
40
|
+
|
|
41
|
+
// Returns `true` once the search should stop.
|
|
42
|
+
function visit(node: TreeNode, depth: number): boolean {
|
|
43
|
+
if (node === null || node === undefined) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isObject = typeof node === 'object';
|
|
48
|
+
|
|
49
|
+
if (isObject) {
|
|
50
|
+
if (seen.has(node)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
seen.add(node);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (predicate(node)) {
|
|
58
|
+
found.push(node);
|
|
59
|
+
|
|
60
|
+
if (!all) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!isObject || depth >= maxDepth) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const keys = Array.isArray(node) ? node.keys() : (walkable ?? Object.keys(node));
|
|
70
|
+
|
|
71
|
+
for (const key of keys) {
|
|
72
|
+
if (typeof key === 'string' && ignore.includes(key)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (visit(node[key], depth + 1)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
visit(root, 0);
|
|
85
|
+
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function reactOptions({ walkable = [], ...rest }: TreeSearchOptions): TreeSearchOptions {
|
|
90
|
+
return { ...rest, walkable: [...REACT_KEYS, ...walkable] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isElement(node: TreeNode): boolean {
|
|
94
|
+
return typeof node === 'object' && ELEMENT_TYPES.has(node.$$typeof);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isNamed(type: TreeNode, name: string): boolean {
|
|
98
|
+
if (typeof type === 'string') {
|
|
99
|
+
return type === name;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!type) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (type.displayName === name) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (type.$$typeof === MEMO) {
|
|
111
|
+
return isNamed(type.type, name);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (type.$$typeof === FORWARD_REF) {
|
|
115
|
+
return isNamed(type.render, name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return typeof type === 'function' && type.name === name;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function elementPredicate(match: ComponentMatch): TreePredicate {
|
|
122
|
+
return (node) =>
|
|
123
|
+
isElement(node) && (typeof match === 'string' ? isNamed(node.type, match) : match(node));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sameEntries(left: Iterable<[unknown, unknown]>, right: Map<unknown, unknown>): boolean {
|
|
127
|
+
return [...left].every(([key, value]) => right.has(key) && Object.is(value, right.get(key)));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function shallow<T>(previous: T, next: T): boolean {
|
|
131
|
+
if (Object.is(previous, next)) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (
|
|
136
|
+
typeof previous !== 'object' ||
|
|
137
|
+
previous === null ||
|
|
138
|
+
typeof next !== 'object' ||
|
|
139
|
+
next === null
|
|
140
|
+
) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (previous instanceof Map && next instanceof Map) {
|
|
145
|
+
return previous.size === next.size && sameEntries(previous, next);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (previous instanceof Set && next instanceof Set) {
|
|
149
|
+
return previous.size === next.size && [...previous].every((item) => next.has(item));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const left = Object.entries(previous);
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
left.length === Object.keys(next).length && sameEntries(left, new Map(Object.entries(next)))
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function useShallow<S, U>(selector: (state: S) => U): (state: S) => U {
|
|
160
|
+
const previous = useRef<U | undefined>(undefined);
|
|
161
|
+
|
|
162
|
+
return (state) => {
|
|
163
|
+
const next = selector(state);
|
|
164
|
+
|
|
165
|
+
if (previous.current !== undefined && shallow(previous.current, next)) {
|
|
166
|
+
return previous.current;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
previous.current = next;
|
|
170
|
+
|
|
171
|
+
return next;
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function findInTree<T = TreeNode>(
|
|
176
|
+
root: TreeNode,
|
|
177
|
+
predicate: TreePredicate,
|
|
178
|
+
options: TreeSearchOptions = {},
|
|
179
|
+
): T | undefined {
|
|
180
|
+
return walkTree(root, predicate, options, false)[0];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function filterInTree<T = TreeNode>(
|
|
184
|
+
root: TreeNode,
|
|
185
|
+
predicate: TreePredicate,
|
|
186
|
+
options: TreeSearchOptions = {},
|
|
187
|
+
): T[] {
|
|
188
|
+
return walkTree(root, predicate, options, true);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @description Creates the fake `utils`: the client's tree searches, component finders and shallow comparison, over any object graph.
|
|
193
|
+
* @returns The namespace, with `react` nested as on device.
|
|
194
|
+
*/
|
|
195
|
+
export function createFakeUtils() {
|
|
196
|
+
const react = {
|
|
197
|
+
findComponent: (tree: TreeNode, match: ComponentMatch) =>
|
|
198
|
+
findInTree(tree, elementPredicate(match), ELEMENT_OPTIONS),
|
|
199
|
+
filterComponents: (tree: TreeNode, match: ComponentMatch) =>
|
|
200
|
+
filterInTree(tree, elementPredicate(match), ELEMENT_OPTIONS),
|
|
201
|
+
findInTree: <T = TreeNode>(
|
|
202
|
+
root: TreeNode,
|
|
203
|
+
predicate: TreePredicate,
|
|
204
|
+
options?: TreeSearchOptions,
|
|
205
|
+
) => findInTree<T>(root, predicate, reactOptions(options ?? {})),
|
|
206
|
+
filterInTree: <T = TreeNode>(
|
|
207
|
+
root: TreeNode,
|
|
208
|
+
predicate: TreePredicate,
|
|
209
|
+
options?: TreeSearchOptions,
|
|
210
|
+
) => filterInTree<T>(root, predicate, reactOptions(options ?? {})),
|
|
211
|
+
shallow,
|
|
212
|
+
useShallow,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
return { findInTree, filterInTree, react };
|
|
216
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseIcu,
|
|
3
|
+
type IcuArgumentNode,
|
|
4
|
+
type IcuNode,
|
|
5
|
+
type IcuPluralNode,
|
|
6
|
+
type IcuSelectNode,
|
|
7
|
+
} from './icu-parse';
|
|
8
|
+
|
|
9
|
+
/** A value `format` puts into a placeholder, as the client's `FormatVars` types it. */
|
|
10
|
+
export type IcuValue = string | number | boolean | Date;
|
|
11
|
+
|
|
12
|
+
/** The values of a message's placeholders, by name. */
|
|
13
|
+
export type IcuVars = Record<string, IcuValue>;
|
|
14
|
+
|
|
15
|
+
const DATE_STYLES = new Set(['short', 'medium', 'long', 'full']);
|
|
16
|
+
|
|
17
|
+
function numberOptions(style: string): Intl.NumberFormatOptions {
|
|
18
|
+
if (style === 'integer') {
|
|
19
|
+
return { maximumFractionDigits: 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (style === 'percent') {
|
|
23
|
+
return { style: 'percent' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function dateOptions(type: string, style: string): Intl.DateTimeFormatOptions {
|
|
30
|
+
const picked = DATE_STYLES.has(style) ? style : 'medium';
|
|
31
|
+
const value = picked as NonNullable<Intl.DateTimeFormatOptions['dateStyle']>;
|
|
32
|
+
|
|
33
|
+
return type === 'time' ? { timeStyle: value } : { dateStyle: value };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function valueOf(vars: IcuVars, name: string): IcuValue {
|
|
37
|
+
const value = vars[name];
|
|
38
|
+
|
|
39
|
+
if (value === undefined) {
|
|
40
|
+
throw new Error(`Failed to format a message: no value for {${name}}.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatArgument(node: IcuArgumentNode, vars: IcuVars, locale: string) {
|
|
47
|
+
const value = valueOf(vars, node.name);
|
|
48
|
+
|
|
49
|
+
if (node.type === 'number') {
|
|
50
|
+
return new Intl.NumberFormat(locale, numberOptions(node.style)).format(Number(value));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (node.type === 'date' || node.type === 'time') {
|
|
54
|
+
const date = value instanceof Date ? value : new Date(Number(value));
|
|
55
|
+
|
|
56
|
+
return new Intl.DateTimeFormat(locale, dateOptions(node.type, node.style)).format(date);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return value instanceof Date ? value.toISOString() : String(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A plural or select without `other` has nothing to fall back to, so formatting it throws.
|
|
63
|
+
function otherBranch(node: IcuPluralNode | IcuSelectNode): IcuNode[] {
|
|
64
|
+
const other = node.branches.get('other');
|
|
65
|
+
|
|
66
|
+
if (!other) {
|
|
67
|
+
throw new Error(`Failed to format a message: {${node.name}} has no "other" branch.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return other;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function pickPlural(node: IcuPluralNode, value: number, locale: string) {
|
|
74
|
+
const exact = node.branches.get(`=${value}`);
|
|
75
|
+
|
|
76
|
+
if (exact) {
|
|
77
|
+
return exact;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const rules = new Intl.PluralRules(locale, { type: node.ordinal ? 'ordinal' : 'cardinal' });
|
|
81
|
+
|
|
82
|
+
return node.branches.get(rules.select(value - node.offset)) ?? otherBranch(node);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatNodes(nodes: IcuNode[], vars: IcuVars, locale: string, pound?: number): string {
|
|
86
|
+
let output = '';
|
|
87
|
+
|
|
88
|
+
for (const node of nodes) {
|
|
89
|
+
switch (node.kind) {
|
|
90
|
+
case 'text':
|
|
91
|
+
output += node.text;
|
|
92
|
+
break;
|
|
93
|
+
case 'pound':
|
|
94
|
+
output += pound === undefined ? '#' : new Intl.NumberFormat(locale).format(pound);
|
|
95
|
+
break;
|
|
96
|
+
case 'argument':
|
|
97
|
+
output += formatArgument(node, vars, locale);
|
|
98
|
+
break;
|
|
99
|
+
case 'plural': {
|
|
100
|
+
const value = Number(valueOf(vars, node.name));
|
|
101
|
+
|
|
102
|
+
output += formatNodes(
|
|
103
|
+
pickPlural(node, value, locale),
|
|
104
|
+
vars,
|
|
105
|
+
locale,
|
|
106
|
+
value - node.offset,
|
|
107
|
+
);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case 'select': {
|
|
111
|
+
const value = String(valueOf(vars, node.name));
|
|
112
|
+
const branch = node.branches.get(value) ?? otherBranch(node);
|
|
113
|
+
|
|
114
|
+
output += formatNodes(branch, vars, locale, pound);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return output;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @description Formats an ICU MessageFormat string in a locale, as Discord's `MessageFormat` does on device.
|
|
125
|
+
* @param message The message.
|
|
126
|
+
* @param vars The values of its placeholders; a placeholder without one throws.
|
|
127
|
+
* @param locale The locale plural rules, numbers and dates follow, such as `de`.
|
|
128
|
+
* @returns The formatted message.
|
|
129
|
+
*/
|
|
130
|
+
export function formatIcu(message: string, vars: IcuVars = {}, locale: string = 'en-US'): string {
|
|
131
|
+
return formatNodes(parseIcu(message), vars, locale);
|
|
132
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/** The branches of a `plural`, `selectordinal` or `select` argument, by selector, such as `one` or `=0`. */
|
|
2
|
+
export type IcuBranches = Map<string, IcuNode[]>;
|
|
3
|
+
|
|
4
|
+
/** A simple argument: `{name}`, or a formatted one such as `{count, number, integer}`. */
|
|
5
|
+
export type IcuArgumentNode = { kind: 'argument'; name: string; type: string; style: string };
|
|
6
|
+
|
|
7
|
+
/** A `plural` or `selectordinal` argument. */
|
|
8
|
+
export type IcuPluralNode = {
|
|
9
|
+
kind: 'plural';
|
|
10
|
+
name: string;
|
|
11
|
+
ordinal: boolean;
|
|
12
|
+
offset: number;
|
|
13
|
+
branches: IcuBranches;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** A `select` argument. */
|
|
17
|
+
export type IcuSelectNode = { kind: 'select'; name: string; branches: IcuBranches };
|
|
18
|
+
|
|
19
|
+
/** One piece of a parsed ICU MessageFormat string. */
|
|
20
|
+
export type IcuNode =
|
|
21
|
+
| { kind: 'text'; text: string }
|
|
22
|
+
| { kind: 'pound' }
|
|
23
|
+
| IcuArgumentNode
|
|
24
|
+
| IcuPluralNode
|
|
25
|
+
| IcuSelectNode;
|
|
26
|
+
|
|
27
|
+
/** A syntax error in an ICU message; `position` is the 1-based character it stopped at. */
|
|
28
|
+
export class IcuSyntaxError extends Error {
|
|
29
|
+
readonly position: number;
|
|
30
|
+
|
|
31
|
+
// Node's strip-only TypeScript, which loads this under the CLI's `rollup -c`, has no parameter properties.
|
|
32
|
+
constructor(reason: string, position: number) {
|
|
33
|
+
super(`${reason} at character ${position}`);
|
|
34
|
+
this.position = position;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Reader = { text: string; index: number };
|
|
39
|
+
|
|
40
|
+
type BranchList = { branches: IcuBranches; offset: number };
|
|
41
|
+
|
|
42
|
+
function isSpace(char: string): boolean {
|
|
43
|
+
return char !== '' && char.trim() === '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fail(reader: Reader, reason: string): never {
|
|
47
|
+
throw new IcuSyntaxError(reason, reader.index + 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function peek(reader: Reader): string {
|
|
51
|
+
return reader.text[reader.index] ?? '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function skipSpace(reader: Reader) {
|
|
55
|
+
while (isSpace(peek(reader))) reader.index++;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readWord(reader: Reader): string {
|
|
59
|
+
skipSpace(reader);
|
|
60
|
+
|
|
61
|
+
const start = reader.index;
|
|
62
|
+
|
|
63
|
+
while (reader.index < reader.text.length) {
|
|
64
|
+
const char = peek(reader);
|
|
65
|
+
|
|
66
|
+
if (isSpace(char) || char === ',' || char === '{' || char === '}') {
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
reader.index++;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return reader.text.slice(start, reader.index);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function expect(reader: Reader, char: string) {
|
|
77
|
+
skipSpace(reader);
|
|
78
|
+
|
|
79
|
+
if (peek(reader) !== char) {
|
|
80
|
+
fail(reader, `Expected "${char}"`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
reader.index++;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// An apostrophe quotes a following `{`, `}` or (in a plural) `#` up to the next lone apostrophe; `''` is one apostrophe.
|
|
87
|
+
function readQuoted(reader: Reader, inPlural: boolean): string {
|
|
88
|
+
const next = reader.text[reader.index + 1] ?? '';
|
|
89
|
+
|
|
90
|
+
if (next === "'") {
|
|
91
|
+
reader.index += 2;
|
|
92
|
+
|
|
93
|
+
return "'";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (next !== '{' && next !== '}' && !(inPlural && next === '#')) {
|
|
97
|
+
reader.index++;
|
|
98
|
+
|
|
99
|
+
return "'";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let quoted = '';
|
|
103
|
+
|
|
104
|
+
reader.index++;
|
|
105
|
+
|
|
106
|
+
while (reader.index < reader.text.length) {
|
|
107
|
+
const char = peek(reader);
|
|
108
|
+
|
|
109
|
+
if (char === "'" && reader.text[reader.index + 1] === "'") {
|
|
110
|
+
quoted += "'";
|
|
111
|
+
reader.index += 2;
|
|
112
|
+
} else if (char === "'") {
|
|
113
|
+
reader.index++;
|
|
114
|
+
|
|
115
|
+
return quoted;
|
|
116
|
+
} else {
|
|
117
|
+
quoted += char;
|
|
118
|
+
reader.index++;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return quoted;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readMessage(reader: Reader, nested: boolean, inPlural: boolean): IcuNode[] {
|
|
126
|
+
const nodes: IcuNode[] = [];
|
|
127
|
+
let text = '';
|
|
128
|
+
|
|
129
|
+
function flush() {
|
|
130
|
+
if (text) {
|
|
131
|
+
nodes.push({ kind: 'text', text });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
text = '';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
while (reader.index < reader.text.length) {
|
|
138
|
+
const char = peek(reader);
|
|
139
|
+
|
|
140
|
+
if (char === "'") {
|
|
141
|
+
text += readQuoted(reader, inPlural);
|
|
142
|
+
} else if (char === '{') {
|
|
143
|
+
flush();
|
|
144
|
+
reader.index++;
|
|
145
|
+
nodes.push(readArgument(reader));
|
|
146
|
+
} else if (char === '}') {
|
|
147
|
+
if (!nested) {
|
|
148
|
+
fail(reader, 'Unmatched "}"');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
flush();
|
|
152
|
+
|
|
153
|
+
return nodes;
|
|
154
|
+
} else if (char === '#' && inPlural) {
|
|
155
|
+
flush();
|
|
156
|
+
nodes.push({ kind: 'pound' });
|
|
157
|
+
reader.index++;
|
|
158
|
+
} else {
|
|
159
|
+
text += char;
|
|
160
|
+
reader.index++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (nested) {
|
|
165
|
+
fail(reader, 'Unclosed "{"');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
flush();
|
|
169
|
+
|
|
170
|
+
return nodes;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function readBranches(reader: Reader, inPlural: boolean): BranchList {
|
|
174
|
+
const branches: IcuBranches = new Map();
|
|
175
|
+
let offset = 0;
|
|
176
|
+
|
|
177
|
+
skipSpace(reader);
|
|
178
|
+
|
|
179
|
+
while (reader.index < reader.text.length && peek(reader) !== '}') {
|
|
180
|
+
const selector = readWord(reader);
|
|
181
|
+
|
|
182
|
+
if (!selector) {
|
|
183
|
+
fail(reader, 'Expected a selector');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (selector.startsWith('offset:')) {
|
|
187
|
+
offset = Number(selector.slice('offset:'.length));
|
|
188
|
+
} else {
|
|
189
|
+
expect(reader, '{');
|
|
190
|
+
branches.set(selector, readMessage(reader, true, inPlural));
|
|
191
|
+
expect(reader, '}');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
skipSpace(reader);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { branches, offset };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function readStyle(reader: Reader): string {
|
|
201
|
+
const start = reader.index;
|
|
202
|
+
let depth = 0;
|
|
203
|
+
|
|
204
|
+
while (reader.index < reader.text.length) {
|
|
205
|
+
const char = peek(reader);
|
|
206
|
+
|
|
207
|
+
if (char === '}' && depth === 0) {
|
|
208
|
+
return reader.text.slice(start, reader.index).trim();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (char === '{') {
|
|
212
|
+
depth++;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (char === '}') {
|
|
216
|
+
depth--;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
reader.index++;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return fail(reader, 'Unclosed argument');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function readArgument(reader: Reader): IcuNode {
|
|
226
|
+
const name = readWord(reader);
|
|
227
|
+
|
|
228
|
+
if (!name) {
|
|
229
|
+
fail(reader, 'Expected an argument name');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
skipSpace(reader);
|
|
233
|
+
|
|
234
|
+
if (peek(reader) === '}') {
|
|
235
|
+
reader.index++;
|
|
236
|
+
|
|
237
|
+
return { kind: 'argument', name, type: '', style: '' };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
expect(reader, ',');
|
|
241
|
+
|
|
242
|
+
const type = readWord(reader);
|
|
243
|
+
let node: IcuNode;
|
|
244
|
+
|
|
245
|
+
skipSpace(reader);
|
|
246
|
+
|
|
247
|
+
if (type === 'plural' || type === 'selectordinal') {
|
|
248
|
+
expect(reader, ',');
|
|
249
|
+
|
|
250
|
+
const { branches, offset } = readBranches(reader, true);
|
|
251
|
+
|
|
252
|
+
node = { kind: 'plural', name, ordinal: type === 'selectordinal', offset, branches };
|
|
253
|
+
} else if (type === 'select') {
|
|
254
|
+
expect(reader, ',');
|
|
255
|
+
node = { kind: 'select', name, branches: readBranches(reader, false).branches };
|
|
256
|
+
} else {
|
|
257
|
+
if (peek(reader) === ',') {
|
|
258
|
+
reader.index++;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
node = { kind: 'argument', name, type, style: readStyle(reader) };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
expect(reader, '}');
|
|
265
|
+
|
|
266
|
+
return node;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @description Parses an ICU MessageFormat string: arguments, `number`/`date`/`time`, `plural`, `selectordinal`,
|
|
271
|
+
* `select`, `#`, `offset:` and apostrophe quoting. The CLI's i18n typings and checks and the testing kit share it.
|
|
272
|
+
* @param message The message, such as `{count, plural, one {# file} other {# files}}`.
|
|
273
|
+
* @returns The message's nodes; throws an `IcuSyntaxError` naming the position of a syntax error.
|
|
274
|
+
*/
|
|
275
|
+
export function parseIcu(message: string): IcuNode[] {
|
|
276
|
+
return readMessage({ text: message, index: 0 }, false, false);
|
|
277
|
+
}
|
package/testing/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export {
|
|
2
|
+
toasts,
|
|
3
|
+
dialogs,
|
|
4
|
+
commands,
|
|
5
|
+
patches,
|
|
6
|
+
flux,
|
|
7
|
+
metro,
|
|
8
|
+
native,
|
|
9
|
+
plugins,
|
|
10
|
+
fs,
|
|
11
|
+
logs,
|
|
12
|
+
setLocale,
|
|
13
|
+
extendApi,
|
|
14
|
+
} from './recorders';
|
|
15
|
+
export { act, createTestRenderer, type TestRenderer } from './test-renderer';
|
|
16
|
+
export { startPlugin, stopPlugin, pluginHolds } from './plugin-lifecycle';
|
|
17
|
+
export { render, type RenderOptions, type RenderResult } from './render';
|
|
18
|
+
export * from './install-addon-globals';
|
|
19
|
+
export * from './fake-native-bridge';
|
|
20
|
+
export * from './fake-react-native';
|
|
21
|
+
export * from './listener-registry';
|
|
22
|
+
export * from './recording-patcher';
|
|
23
|
+
export * from './fake-components';
|
|
24
|
+
export * from './memory-storage';
|
|
25
|
+
export * from './fake-feedback';
|
|
26
|
+
export * from './fake-settings';
|
|
27
|
+
export * from './fake-locales';
|
|
28
|
+
export * from './fake-plugins';
|
|
29
|
+
export * from './plugin-types';
|
|
30
|
+
export * from './fake-native';
|
|
31
|
+
export * from './fake-timers';
|
|
32
|
+
export * from './react-value';
|
|
33
|
+
export * from './fake-metro';
|
|
34
|
+
export * from './fake-react';
|
|
35
|
+
export * from './fake-utils';
|
|
36
|
+
export * from './icu-format';
|
|
37
|
+
export * from './fake-java';
|
|
38
|
+
export * from './fake-objc';
|
|
39
|
+
export * from './icu-parse';
|
|
40
|
+
export * from './test-node';
|
|
41
|
+
export * from './fake-api';
|
|
42
|
+
export * from './fake-fs';
|