@terrafirmagreg-team/game 0.0.0-stage → 1.2.0

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.
@@ -0,0 +1,306 @@
1
+ 'use client';
2
+
3
+ import { useAsyncResult } from '@terrafirmagreg-team/ui/hooks/use-async-value';
4
+ import { useCycleIndex } from '@terrafirmagreg-team/ui/hooks/use-cycle-index';
5
+ import {
6
+ classifyRefId,
7
+ isTagKind,
8
+ parseStackId,
9
+ tagKindOf,
10
+ } from '@terrafirmagreg-team/dump/lib/dump/contract/ref';
11
+ import { texturePublicUrl } from '@terrafirmagreg-team/dump/lib/dump/assets/urls';
12
+ import { useDumpLocation } from '@terrafirmagreg-team/game/hooks/use-dump-location';
13
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
14
+ import { lookupSpriteInBundle } from '@terrafirmagreg-team/dump/lib/dump/assets/atlas/lookup';
15
+ import type { AtlasLookup } from '@terrafirmagreg-team/dump/lib/dump/assets/atlas/dto';
16
+ import {
17
+ blockItemId,
18
+ fetchAtlases,
19
+ fetchEntityTexture,
20
+ fetchTagMembers,
21
+ peekBlockItem,
22
+ peekEntityTexture,
23
+ peekTagMembers,
24
+ } from '@terrafirmagreg-team/game/lib/entry/icon-load';
25
+ import { loadClientModAtlases } from '@terrafirmagreg-team/dump/lib/dump/client/atlases';
26
+ import { stackKindOf } from '@terrafirmagreg-team/game/lib/entry/icon';
27
+ import type {
28
+ DumpNbt,
29
+ ReferenceKind,
30
+ TaggedReference,
31
+ } from '@terrafirmagreg-team/dump/lib/dump/contract/ref';
32
+
33
+ export const ENTRY_CYCLE_MS = 1500;
34
+
35
+ export type IconSource = {
36
+
37
+ rawId: string;
38
+
39
+ cycleRef: TaggedReference | null;
40
+
41
+ lookup: AtlasLookup | null;
42
+
43
+ missingLookup: AtlasLookup | null;
44
+ missingPending: boolean;
45
+
46
+ imgSrc: string | undefined;
47
+
48
+ sourceKey: string;
49
+
50
+ pending: boolean;
51
+ awaitingAtlas: boolean;
52
+ awaitingTexture: boolean;
53
+ usesAtlas: boolean;
54
+ isEntity: boolean;
55
+ };
56
+
57
+ export function useIconSource({
58
+ dataset,
59
+ reference,
60
+ id,
61
+ kind,
62
+ tag,
63
+ missing,
64
+ preferVariant,
65
+ entityView = 'auto',
66
+ }: {
67
+ dataset?: DatasetRef;
68
+ reference?: TaggedReference;
69
+ id?: string;
70
+ kind?: ReferenceKind;
71
+ tag?: DumpNbt;
72
+ missing: 'placeholder' | 'item' | 'hide';
73
+ preferVariant?: boolean;
74
+ entityView?: 'auto' | 'texture' | 'egg';
75
+ }): IconSource {
76
+ const ref = reference ?? (id ? classifyRefId(id, kind) : undefined);
77
+ const refKind = ref?.kind;
78
+ const rawId = ref?.id ?? id ?? '';
79
+ const nbt = tag ?? ref?.tag;
80
+ const tagMemberKind = tagKindOf(refKind);
81
+ const tagKey =
82
+ dataset && rawId && isTagKind(refKind)
83
+ ? `${dataset.id}/${dataset.version ?? ''}/${tagMemberKind ?? 'item'}/${rawId}`
84
+ : null;
85
+ const tagMembersPeek = tagKey
86
+ ? peekTagMembers(dataset!, rawId, tagMemberKind ?? 'item')
87
+ : undefined;
88
+ const tagHit = useAsyncResult(
89
+ tagKey && tagMembersPeek === undefined
90
+ ? async () => ({
91
+ key: tagKey,
92
+ members: await fetchTagMembers(
93
+ dataset!,
94
+ rawId,
95
+ tagMemberKind ?? 'item',
96
+ ),
97
+ })
98
+ : false,
99
+ [dataset, rawId, tagMemberKind, tagKey, tagMembersPeek],
100
+ );
101
+ const tagMembers = !tagKey
102
+ ? undefined
103
+ : tagMembersPeek !== undefined
104
+ ? tagMembersPeek
105
+ : tagHit.pending
106
+ ? undefined
107
+ : tagHit.value?.key === tagKey
108
+ ? (tagHit.value.members ?? null)
109
+ : null;
110
+ const cycleRefs =
111
+ tagMembers && tagMembers.length > 1
112
+ ? tagMembers.map((member) => ({
113
+ kind: (tagMemberKind ?? 'item') as ReferenceKind,
114
+ id: member,
115
+ }))
116
+ : null;
117
+ const [cycleIndex] = useCycleIndex(cycleRefs?.length ?? 0, {
118
+ enabled: cycleRefs !== null,
119
+ intervalMs: ENTRY_CYCLE_MS,
120
+ });
121
+ const itemId = !rawId
122
+ ? ''
123
+ : tagKey
124
+ ? tagMembers === undefined
125
+ ? undefined
126
+ : tagMembers === null
127
+ ? null
128
+ : (tagMembers[0] ?? null)
129
+ : rawId;
130
+ const memberKind =
131
+ isTagKind(refKind) && itemId && !itemId.startsWith('#')
132
+ ? (tagMemberKind ?? 'item')
133
+ : refKind;
134
+ const blockKey =
135
+ dataset && memberKind === 'block' && itemId && !itemId.startsWith('#')
136
+ ? `${dataset.id}/${dataset.version ?? ''}/${itemId}`
137
+ : null;
138
+ const blockPeek = blockKey ? peekBlockItem(dataset!, itemId!) : undefined;
139
+ const blockHit = useAsyncResult(
140
+ blockKey && blockPeek === undefined
141
+ ? async () => ({
142
+ key: blockKey,
143
+ id: await blockItemId(dataset!, itemId!),
144
+ })
145
+ : false,
146
+ [dataset, itemId, blockKey, blockPeek],
147
+ );
148
+ const blockResolved = !blockKey
149
+ ? undefined
150
+ : blockPeek !== undefined
151
+ ? blockPeek
152
+ : blockHit.pending
153
+ ? undefined
154
+ : blockHit.value?.key === blockKey
155
+ ? (blockHit.value.id ?? null)
156
+ : null;
157
+ const atlasId = blockKey ? (blockResolved ?? undefined) : itemId;
158
+ const displayKind = blockKey && blockResolved ? 'item' : memberKind;
159
+ const resolvedId = atlasId ?? rawId;
160
+ const isTexture = displayKind === 'texture';
161
+ const isEntity = displayKind === 'entity';
162
+ const parsed = parseStackId(resolvedId.replace(/^#/, ''));
163
+ const spriteId = parsed.id;
164
+ const eggId =
165
+ isEntity && entityView !== 'texture' && spriteId && !spriteId.startsWith('#')
166
+ ? `${spriteId}_spawn_egg`
167
+ : undefined;
168
+ const usesAtlas =
169
+ Boolean(dataset && rawId) &&
170
+ (displayKind === 'item' ||
171
+ displayKind === 'block' ||
172
+ displayKind === 'fluid' ||
173
+ Boolean(eggId));
174
+ const modId = spriteId.includes(':')
175
+ ? spriteId.slice(0, spriteId.indexOf(':'))
176
+ : '';
177
+ const atlasNeedsLoad =
178
+ dataset && usesAtlas && modId && spriteId && !spriteId.startsWith('#');
179
+ const atlasPeek = atlasNeedsLoad
180
+ ? loadClientModAtlases.peek(dataset, modId)
181
+ : undefined;
182
+ const atlasBundle =
183
+ atlasPeek && (atlasPeek.items || atlasPeek.blocks) ? atlasPeek : null;
184
+ const atlases = useAsyncResult(
185
+ atlasNeedsLoad && atlasPeek === undefined
186
+ ? () => fetchAtlases(dataset, modId)
187
+ : false,
188
+ [atlasNeedsLoad, atlasPeek, dataset, modId],
189
+ );
190
+ const atlasValue = atlasPeek === undefined ? atlases.value : atlasBundle;
191
+ const sourceKey = `${dataset?.id}/${dataset?.version ?? ''}:${displayKind}:${resolvedId}`;
192
+ const dumpLoc = useDumpLocation(dataset);
193
+ const wantEntityTexture = isEntity && entityView !== 'egg';
194
+ const texturePeek =
195
+ dataset && isTexture && rawId
196
+ ? texturePublicUrl(dataset, rawId)
197
+ : dataset && wantEntityTexture && resolvedId
198
+ ? peekEntityTexture(dataset, resolvedId, nbt)
199
+ : null;
200
+ const textureSrc = useAsyncResult(
201
+ dataset && isTexture && rawId
202
+ ? false
203
+ : dataset &&
204
+ wantEntityTexture &&
205
+ resolvedId &&
206
+ texturePeek === undefined
207
+ ? async () => ({
208
+ key: sourceKey,
209
+ url:
210
+ (await fetchEntityTexture(dataset, resolvedId, nbt)) ?? undefined,
211
+ })
212
+ : false,
213
+ [
214
+ isTexture,
215
+ isEntity,
216
+ wantEntityTexture,
217
+ dataset,
218
+ rawId,
219
+ resolvedId,
220
+ sourceKey,
221
+ nbt,
222
+ texturePeek,
223
+ ],
224
+ );
225
+ const resolvedTexture =
226
+ texturePeek !== undefined
227
+ ? (texturePeek ?? undefined)
228
+ : textureSrc.value?.key === sourceKey
229
+ ? textureSrc.value.url
230
+ : undefined;
231
+ const wantMissingItem =
232
+ missing === 'item' && dataset !== undefined && usesAtlas;
233
+ const missingPeek = wantMissingItem
234
+ ? loadClientModAtlases.peek(dataset, 'ftbquests')
235
+ : undefined;
236
+ const missingBundleValue =
237
+ missingPeek !== undefined
238
+ ? missingPeek.items || missingPeek.blocks
239
+ ? missingPeek
240
+ : null
241
+ : undefined;
242
+ const missingBundle = useAsyncResult(
243
+ wantMissingItem && missingPeek === undefined
244
+ ? () => fetchAtlases(dataset, 'ftbquests')
245
+ : false,
246
+ [wantMissingItem, missingPeek, dataset],
247
+ );
248
+ const missingLookup = wantMissingItem
249
+ ? lookupSpriteInBundle(
250
+ missingBundleValue === undefined
251
+ ? missingBundle.value
252
+ : missingBundleValue,
253
+ {
254
+ id: 'ftbquests:missing_item',
255
+ kind: 'item',
256
+ },
257
+ )
258
+ : null;
259
+
260
+ const lookupKind = stackKindOf(displayKind) ?? (eggId ? 'item' : undefined);
261
+ const lookup = usesAtlas
262
+ ? lookupSpriteInBundle(
263
+ atlasValue,
264
+ {
265
+ id: eggId ?? spriteId,
266
+ ...(lookupKind ? { kind: lookupKind } : {}),
267
+ ...(nbt !== undefined
268
+ ? { tag: nbt }
269
+ : parsed.nbt !== undefined
270
+ ? { tag: parsed.nbt }
271
+ : {}),
272
+ },
273
+ lookupKind,
274
+ preferVariant,
275
+ )
276
+ : null;
277
+ const pending =
278
+ (isTagKind(refKind) && itemId === undefined) ||
279
+ (blockKey !== null && blockResolved === undefined);
280
+ const awaitingAtlas =
281
+ usesAtlas &&
282
+ atlasId != null &&
283
+ !atlasId.startsWith('#') &&
284
+ atlases.pending;
285
+ const awaitingTexture =
286
+ (isTexture || isEntity) &&
287
+ texturePeek === undefined &&
288
+ (textureSrc.pending || (isTexture && !dumpLoc));
289
+
290
+ return {
291
+ rawId,
292
+ cycleRef:
293
+ cycleRefs && !pending ? (cycleRefs[cycleIndex] ?? cycleRefs[0]!) : null,
294
+ lookup: lookup ?? null,
295
+ missingLookup: missingLookup ?? null,
296
+ missingPending: missingBundle.pending,
297
+ imgSrc: isTexture || isEntity ? resolvedTexture : undefined,
298
+ sourceKey,
299
+ pending,
300
+ awaitingAtlas,
301
+ awaitingTexture,
302
+ usesAtlas,
303
+ isEntity,
304
+ };
305
+ }
306
+
@@ -0,0 +1,20 @@
1
+ 'use client';
2
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
3
+
4
+ import { useAsyncResult } from '@terrafirmagreg-team/ui/hooks/use-async-value';
5
+ import { dumpReader } from '@terrafirmagreg-team/dump/lib/dump/reader';
6
+
7
+ export function useLangMap(
8
+ dataset: DatasetRef | undefined,
9
+ lang: string | undefined,
10
+ enabled = true,
11
+ ): Record<string, string> | undefined {
12
+ const { value, pending } = useAsyncResult(
13
+ enabled && dataset && lang
14
+ ? () => dumpReader(dataset).then((reader) => reader.getLang(lang))
15
+ : false,
16
+ [dataset, lang, enabled],
17
+ );
18
+ return pending ? undefined : value;
19
+ }
20
+
@@ -0,0 +1,5 @@
1
+ export {
2
+ stackDisplay,
3
+ stackDisplayName,
4
+ } from '@terrafirmagreg-team/dump/lib/dump/assets/lang/names';
5
+
@@ -0,0 +1,59 @@
1
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
2
+ import { entryHref } from '@terrafirmagreg-team/game/lib/entry/href';
3
+ import { tagKindOf } from '@terrafirmagreg-team/dump/lib/dump/contract/ref';
4
+ import type { ReferenceKind } from '@terrafirmagreg-team/dump/lib/dump/contract/ref';
5
+ import type { EntryProps } from '@terrafirmagreg-team/game/types/entry';
6
+
7
+ export function usesStackTooltip(kind: ReferenceKind | undefined): boolean {
8
+ switch (kind) {
9
+ case 'texture':
10
+ case 'model':
11
+ case 'recipe':
12
+ case 'quest':
13
+ case 'guide':
14
+ case 'chapter':
15
+ return false;
16
+ default:
17
+ return true;
18
+ }
19
+ }
20
+
21
+ export function tooltipLoadKind(
22
+ kind: ReferenceKind | undefined,
23
+ ): 'item' | 'fluid' | 'entity' | undefined {
24
+ const member = tagKindOf(kind) ?? kind;
25
+ if (member === 'fluid') return 'fluid';
26
+ if (member === 'entity') return 'entity';
27
+ return 'item';
28
+ }
29
+
30
+ export function resolveEntryHref(
31
+ props: EntryProps & { dataset: DatasetRef },
32
+ ): string | undefined {
33
+ if (props.href === false) return undefined;
34
+ if (typeof props.href === 'string') return props.href;
35
+ if (!props.lang) return undefined;
36
+ const kind = props.reference?.kind ?? props.kind ?? 'item';
37
+ const id = props.reference?.id ?? props.id;
38
+ if (!id) return undefined;
39
+ if (kind === 'entity' || kind === 'texture' || kind === 'model') {
40
+ return undefined;
41
+ }
42
+ return entryHref(
43
+ props.dataset,
44
+ props.lang,
45
+ props.reference ?? { kind, id, ...(props.tag !== undefined ? { tag: props.tag } : {}) },
46
+ {
47
+ tab: props.tab,
48
+ type: props.type,
49
+ search: props.search,
50
+ rpage: props.rpage,
51
+ filename: props.filename,
52
+ modId: props.modId,
53
+ bookPath: props.bookPath,
54
+ entry: props.entry,
55
+ hash: props.hash,
56
+ },
57
+ );
58
+ }
59
+
@@ -0,0 +1,227 @@
1
+ const ELEMENT_COLORS: Record<string, string> = {
2
+ H: '#FFFFFF',
3
+ D: '#FFFFC0',
4
+ T: '#FFFFA0',
5
+ He: '#D9FFFF',
6
+ Li: '#CC80FF',
7
+ Be: '#C2FF00',
8
+ B: '#FFB5B5',
9
+ C: '#909090',
10
+ N: '#3050F8',
11
+ O: '#FF0D0D',
12
+ F: '#90E050',
13
+ Ne: '#B3E3F5',
14
+ Na: '#AB5CF2',
15
+ Mg: '#8AFF00',
16
+ Al: '#BFA6A6',
17
+ Si: '#F0C8A0',
18
+ P: '#FF8000',
19
+ S: '#FFFF30',
20
+ Cl: '#1FF01F',
21
+ Ar: '#80D1E3',
22
+ K: '#8F40D4',
23
+ Ca: '#3DFF00',
24
+ Sc: '#E6E6E6',
25
+ Ti: '#BFC2C7',
26
+ V: '#A6A6AB',
27
+ Cr: '#8A99C7',
28
+ Mn: '#9C7AC7',
29
+ Fe: '#E06633',
30
+ Co: '#F090A0',
31
+ Ni: '#50D050',
32
+ Cu: '#C88033',
33
+ Zn: '#7D80B0',
34
+ Ga: '#C28F8F',
35
+ Ge: '#668F8F',
36
+ As: '#BD80E3',
37
+ Se: '#FFA100',
38
+ Br: '#A62929',
39
+ Kr: '#5CB8D1',
40
+ Rb: '#702EB0',
41
+ Sr: '#00FF00',
42
+ Y: '#94FFFF',
43
+ Zr: '#94E0E0',
44
+ Nb: '#73C2C9',
45
+ Mo: '#54B5B5',
46
+ Tc: '#3B9E9E',
47
+ Ru: '#248F8F',
48
+ Rh: '#0A7D8C',
49
+ Pd: '#000000',
50
+ Ag: '#C0C0C0',
51
+ Cd: '#FFD98F',
52
+ In: '#A67573',
53
+ Sn: '#668080',
54
+ Sb: '#9E63B5',
55
+ Te: '#D47A00',
56
+ I: '#940094',
57
+ Xe: '#429EB0',
58
+ Cs: '#57178F',
59
+ Ba: '#00C900',
60
+ La: '#70D4FF',
61
+ Ce: '#FFFFC7',
62
+ Pr: '#D9FFC7',
63
+ Nd: '#C7FFC7',
64
+ Pm: '#A3FFC7',
65
+ Sm: '#8FFFC7',
66
+ Eu: '#61FFC7',
67
+ Gd: '#45FFC7',
68
+ Tb: '#30FFC7',
69
+ Dy: '#1FFFC7',
70
+ Ho: '#00FF9C',
71
+ Er: '#00E675',
72
+ Tm: '#00D452',
73
+ Yb: '#00BF38',
74
+ Lu: '#00AB24',
75
+ Hf: '#4DC2FF',
76
+ Ta: '#4DA6FF',
77
+ W: '#2194D6',
78
+ Re: '#267DAB',
79
+ Os: '#266696',
80
+ Ir: '#175487',
81
+ Pt: '#D0D0E0',
82
+ Au: '#FFD123',
83
+ Hg: '#B8B8D0',
84
+ Tl: '#A6544D',
85
+ Pb: '#575961',
86
+ Bi: '#9E4FB5',
87
+ Po: '#AB5C00',
88
+ At: '#754F45',
89
+ Rn: '#428296',
90
+ Fr: '#420066',
91
+ Ra: '#000000',
92
+ Ac: '#70ABFA',
93
+ Th: '#00BAFF',
94
+ Pa: '#00A1FF',
95
+ U: '#008FFF',
96
+ Np: '#0080FF',
97
+ Pu: '#000000',
98
+ Am: '#545CF2',
99
+ Cm: '#785CE3',
100
+ Bk: '#8A4FE3',
101
+ Cf: '#A136D4',
102
+ Es: '#B31FD4',
103
+ Fm: '#B31FBA',
104
+ Md: '#B30DA6',
105
+ No: '#BD0D87',
106
+ Lr: '#C70066',
107
+ Rf: '#CC0059',
108
+ Db: '#D1004F',
109
+ Sg: '#D90045',
110
+ Bh: '#E00038',
111
+ Hs: '#E6002E',
112
+ Mt: '#EB0026',
113
+ Ds: '#EB0026',
114
+ Rg: '#FFFF55',
115
+ Cn: '#FFFF55',
116
+ Nh: '#FFFF55',
117
+ Fl: '#FFFF55',
118
+ Mc: '#FFFF55',
119
+ Lv: '#FFFF55',
120
+ Ts: '#FFFF55',
121
+ Og: '#FFFF55',
122
+ Nq: '#FFFF55',
123
+ Ke: '#FFFF55',
124
+ Tr: '#FFFF55',
125
+ Dr: '#FFFF55',
126
+ Nt: '#FFFF55',
127
+ };
128
+
129
+ const MIN_BRIGHTNESS = 0.1;
130
+
131
+ function lightenHex(hex: string): string {
132
+ const value = Number.parseInt(hex.slice(1), 16);
133
+ const r = (value >> 16) & 0xff;
134
+ const g = (value >> 8) & 0xff;
135
+ const b = value & 0xff;
136
+ const max = Math.max(r, g, b) / 255;
137
+ const min = Math.min(r, g, b) / 255;
138
+ const delta = max - min;
139
+ let h = 0;
140
+ if (delta !== 0) {
141
+ if (max === r / 255) h = ((g / 255 - b / 255) / delta) % 6;
142
+ else if (max === g / 255) h = (b / 255 - r / 255) / delta + 2;
143
+ else h = (r / 255 - g / 255) / delta + 4;
144
+ h *= 60;
145
+ if (h < 0) h += 360;
146
+ }
147
+ const s = max === 0 ? 0 : delta / max;
148
+ const v = Math.max(max, MIN_BRIGHTNESS);
149
+ const c = v * s;
150
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
151
+ const m = v - c;
152
+ const [rp, gp, bp] =
153
+ h < 60
154
+ ? [c, x, 0]
155
+ : h < 120
156
+ ? [x, c, 0]
157
+ : h < 180
158
+ ? [0, c, x]
159
+ : h < 240
160
+ ? [0, x, c]
161
+ : h < 300
162
+ ? [x, 0, c]
163
+ : [c, 0, x];
164
+ const to255 = (n: number) =>
165
+ Math.round((n + m) * 255)
166
+ .toString(16)
167
+ .padStart(2, '0');
168
+ return `#${to255(rp)}${to255(gp)}${to255(bp)}`;
169
+ }
170
+
171
+ const LIGHTENED = new Map<string, string>();
172
+ function elementColor(symbol: string): string | undefined {
173
+ const raw = ELEMENT_COLORS[symbol];
174
+ if (!raw) return undefined;
175
+ const cached = LIGHTENED.get(symbol);
176
+ if (cached) return cached;
177
+ const color = lightenHex(raw);
178
+ LIGHTENED.set(symbol, color);
179
+ return color;
180
+ }
181
+
182
+ const SUBSCRIPT = '₀₁₂₃₄₅₆₇₈₉';
183
+ const SUPERSCRIPT = '⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻';
184
+ const PUNCT = new Set('()[]{}·∙.*+?=-‐‑‒–—~≈≡,');
185
+
186
+ export type FormulaRun = { text: string; color?: string };
187
+
188
+ export function formulaRuns(plain: string): FormulaRun[] | null {
189
+ if (!plain || plain.length < 2) return null;
190
+ const runs: FormulaRun[] = [];
191
+ let i = 0;
192
+ let elements = 0;
193
+ let pushPlain = (text: string) => {
194
+ const last = runs[runs.length - 1];
195
+ if (last && !last.color) last.text += text;
196
+ else runs.push({ text });
197
+ };
198
+ while (i < plain.length) {
199
+ const ch = plain[i];
200
+ if (ch >= 'A' && ch <= 'Z') {
201
+ const two = plain.slice(i, i + 2);
202
+ const symbol = /^[a-z]$/.test(plain[i + 1] ?? '') ? two : ch;
203
+ const color = elementColor(symbol);
204
+ if (!color) return null;
205
+ runs.push({ text: symbol, color });
206
+ elements += 1;
207
+ i += symbol.length;
208
+ continue;
209
+ }
210
+ if ((ch >= '0' && ch <= '9') || SUBSCRIPT.includes(ch)) {
211
+ let j = i;
212
+ while (j < plain.length && /[0-9]/.test(plain[j])) j += 1;
213
+ while (j < plain.length && SUBSCRIPT.includes(plain[j])) j += 1;
214
+ pushPlain(plain.slice(i, j));
215
+ i = j;
216
+ continue;
217
+ }
218
+ if (SUPERSCRIPT.includes(ch) || PUNCT.has(ch)) {
219
+ pushPlain(ch);
220
+ i += 1;
221
+ continue;
222
+ }
223
+ return null;
224
+ }
225
+ return elements > 0 ? runs : null;
226
+ }
227
+