@terrafirmagreg-team/dump 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.
- package/package.json +28 -3
- package/src/lib/dump/assets/atlas/dto.ts +51 -0
- package/src/lib/dump/assets/atlas/lookup.ts +207 -0
- package/src/lib/dump/assets/atlas/sprites.ts +132 -0
- package/src/lib/dump/assets/lang/load.ts +19 -0
- package/src/lib/dump/assets/lang/names.ts +244 -0
- package/src/lib/dump/assets/urls.ts +145 -0
- package/src/lib/dump/client/atlases.ts +85 -0
- package/src/lib/dump/client/dump.ts +132 -0
- package/src/lib/dump/client/groups.ts +27 -0
- package/src/lib/dump/client/keybinds.ts +16 -0
- package/src/lib/dump/client/mods.ts +18 -0
- package/src/lib/dump/client/registry.ts +121 -0
- package/src/lib/dump/client/tabs.ts +23 -0
- package/src/lib/dump/client/tags.ts +59 -0
- package/src/lib/dump/contract/kind.ts +62 -0
- package/src/lib/dump/contract/read.ts +76 -0
- package/src/lib/dump/contract/ref.ts +260 -0
- package/src/lib/dump/contract/text.ts +101 -0
- package/src/lib/dump/data/registry/dto.ts +57 -0
- package/src/lib/dump/data/registry/parse.ts +291 -0
- package/src/lib/dump/data/tab/dto.ts +14 -0
- package/src/lib/dump/data/tab/parse.ts +27 -0
- package/src/lib/dump/data/tag/dto.ts +8 -0
- package/src/lib/dump/data/tag/parse.ts +58 -0
- package/src/lib/dump/gen/data/guides/book.ts +57 -0
- package/src/lib/dump/gen/data/guides/category.ts +31 -0
- package/src/lib/dump/gen/data/guides/entry.ts +49 -0
- package/src/lib/dump/gen/data/models/model.ts +4 -0
- package/src/lib/dump/gen/data/quests/chapter.ts +127 -0
- package/src/lib/dump/gen/data/quests/chapter_groups.ts +12 -0
- package/src/lib/dump/gen/data/quests/data.ts +41 -0
- package/src/lib/dump/gen/data/recipes/recipe.ts +79 -0
- package/src/lib/dump/gen/index.ts +21 -0
- package/src/lib/dump/gen/keybinds.ts +4 -0
- package/src/lib/dump/gen/lang.ts +4 -0
- package/src/lib/dump/gen/manifest.ts +60 -0
- package/src/lib/dump/gen/registry/blocks.ts +61 -0
- package/src/lib/dump/gen/registry/entities.ts +50 -0
- package/src/lib/dump/gen/registry/fluids.ts +49 -0
- package/src/lib/dump/gen/registry/groups.ts +15 -0
- package/src/lib/dump/gen/registry/items.ts +52 -0
- package/src/lib/dump/gen/registry/recipe_categories.ts +65 -0
- package/src/lib/dump/gen/registry/recipes.ts +4 -0
- package/src/lib/dump/gen/registry/tabs.ts +47 -0
- package/src/lib/dump/gen/registry/tags.ts +12 -0
- package/src/lib/dump/json-reader.ts +254 -0
- package/src/lib/dump/manifest/dto.ts +12 -0
- package/src/lib/dump/manifest/parse.ts +168 -0
- package/src/lib/dump/mods/dto.ts +31 -0
- package/src/lib/dump/mods/index.ts +97 -0
- package/src/lib/dump/mods/load.ts +60 -0
- package/src/lib/dump/paths.ts +172 -0
- package/src/lib/dump/reader.ts +31 -0
- package/src/lib/dump-search.ts +125 -0
- package/src/lib/snbt.ts +247 -0
- package/README.md +0 -3
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
|
|
2
|
+
import { stackIdBase } from '@terrafirmagreg-team/dump/lib/dump/contract/ref';
|
|
3
|
+
|
|
4
|
+
export type DumpLocation = {
|
|
5
|
+
|
|
6
|
+
root: string;
|
|
7
|
+
|
|
8
|
+
files?: Record<string, string>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const dumpLocations = new Map<string, DumpLocation>();
|
|
12
|
+
const dumpLocationListeners = new Set<() => void>();
|
|
13
|
+
|
|
14
|
+
export function registerDumpLocation(
|
|
15
|
+
dataset: DatasetRef,
|
|
16
|
+
location: DumpLocation,
|
|
17
|
+
): void {
|
|
18
|
+
dumpLocations.set(`${dataset.id}/${dataset.version ?? ''}`, location);
|
|
19
|
+
for (const listener of dumpLocationListeners) listener();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function subscribeDumpLocations(listener: () => void): () => void {
|
|
23
|
+
dumpLocationListeners.add(listener);
|
|
24
|
+
return () => dumpLocationListeners.delete(listener);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function dumpLocation(dataset: DatasetRef): DumpLocation | undefined {
|
|
28
|
+
return dumpLocations.get(`${dataset.id}/${dataset.version ?? ''}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function dumpStorageUrl(storageRel: string): string {
|
|
32
|
+
const origin = (
|
|
33
|
+
process.env.NEXT_PUBLIC_DUMP_STORAGE_URL ??
|
|
34
|
+
process.env.DUMP_STORAGE_URL ??
|
|
35
|
+
''
|
|
36
|
+
).replace(/\/+$/, '');
|
|
37
|
+
if (!origin) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'NEXT_PUBLIC_DUMP_STORAGE_URL is not set: dump storage is required.',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return `${origin}/${storageRel}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function dumpPublicUrl(
|
|
46
|
+
dataset: DatasetRef,
|
|
47
|
+
...segments: string[]
|
|
48
|
+
): string | undefined {
|
|
49
|
+
const ds = cleanDumpSegment(dataset.id);
|
|
50
|
+
if (!ds || ds.includes('/')) {
|
|
51
|
+
throw new Error(`Invalid dump dataset segment: ${dataset.id}`);
|
|
52
|
+
}
|
|
53
|
+
const rel = segments
|
|
54
|
+
.map(cleanDumpSegment)
|
|
55
|
+
.filter((s): s is string => Boolean(s))
|
|
56
|
+
.join('/');
|
|
57
|
+
const loc = dumpLocation(dataset);
|
|
58
|
+
if (!loc) return undefined;
|
|
59
|
+
const key = rel ? loc.files?.[rel] : undefined;
|
|
60
|
+
const storageRel = key ?? `${loc.root}${rel ? `/${rel}` : ''}`;
|
|
61
|
+
return dumpStorageUrl(storageRel);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cleanDumpSegment(segment: string): string | null {
|
|
65
|
+
const cleaned = segment.replaceAll('\\', '/').replace(/^\/+/, '');
|
|
66
|
+
if (!cleaned || cleaned.includes('..')) return null;
|
|
67
|
+
return cleaned;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function cleanDumpRel(rel: string): string | null {
|
|
71
|
+
return cleanDumpSegment(rel);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function dumpDirOf(rel: string): string {
|
|
75
|
+
const cleaned = cleanDumpRel(rel) ?? '';
|
|
76
|
+
const slash = cleaned.lastIndexOf('/');
|
|
77
|
+
return slash >= 0 ? cleaned.slice(0, slash) : '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function joinDumpRel(
|
|
81
|
+
baseFileRel: string,
|
|
82
|
+
childRel: string,
|
|
83
|
+
): string | null {
|
|
84
|
+
const child = cleanDumpRel(childRel);
|
|
85
|
+
if (!child) return null;
|
|
86
|
+
const dir = dumpDirOf(baseFileRel);
|
|
87
|
+
return dir ? `${dir}/${child}` : child;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const TAG_REGISTRIES = ['items', 'blocks', 'fluids', 'entities'] as const;
|
|
91
|
+
|
|
92
|
+
export function tagHashId(tagId: string): string {
|
|
93
|
+
return `#${tagId.replace(/^#+/, '')}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function tagIdForms(tagId: string): string[] {
|
|
97
|
+
const raw = tagId.startsWith('#') ? tagId.slice(1) : tagId;
|
|
98
|
+
return [raw, `#${raw}`];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function tagRegistryKeys(
|
|
102
|
+
tagId: string,
|
|
103
|
+
preferredRegistry?: string,
|
|
104
|
+
): string[] {
|
|
105
|
+
const raw = tagId.startsWith('#') ? tagId.slice(1) : tagId;
|
|
106
|
+
const parts = splitDumpId(raw);
|
|
107
|
+
if (!parts) return [];
|
|
108
|
+
const registries = preferredRegistry
|
|
109
|
+
? [
|
|
110
|
+
preferredRegistry,
|
|
111
|
+
...TAG_REGISTRIES.filter((registry) => registry !== preferredRegistry),
|
|
112
|
+
]
|
|
113
|
+
: TAG_REGISTRIES;
|
|
114
|
+
return registries.map((registry) => `${parts.ns}:${registry}/${parts.path}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function matchTagIndexKey(
|
|
118
|
+
entries: Record<string, unknown> | undefined,
|
|
119
|
+
tagId: string,
|
|
120
|
+
preferredRegistry?: string,
|
|
121
|
+
): string | undefined {
|
|
122
|
+
if (!entries) return undefined;
|
|
123
|
+
for (const form of tagIdForms(tagId)) {
|
|
124
|
+
if (entries[form] !== undefined) return form;
|
|
125
|
+
}
|
|
126
|
+
for (const key of tagRegistryKeys(tagId, preferredRegistry)) {
|
|
127
|
+
if (entries[key] !== undefined) return key;
|
|
128
|
+
}
|
|
129
|
+
const raw = tagId.startsWith('#') ? tagId.slice(1) : tagId;
|
|
130
|
+
const parts = splitDumpId(raw);
|
|
131
|
+
if (!parts) return undefined;
|
|
132
|
+
const candidates = Object.keys(entries).filter(
|
|
133
|
+
(key) => key.startsWith(`${parts.ns}:`) && key.endsWith(`/${parts.path}`),
|
|
134
|
+
);
|
|
135
|
+
return candidates.sort()[0];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function splitDumpId(
|
|
139
|
+
id: string,
|
|
140
|
+
): { ns: string; path: string } | undefined {
|
|
141
|
+
const sep = id.indexOf(':');
|
|
142
|
+
if (sep < 1) return undefined;
|
|
143
|
+
const ns = id.slice(0, sep);
|
|
144
|
+
const path = id
|
|
145
|
+
.slice(sep + 1)
|
|
146
|
+
.replaceAll('\\', '/')
|
|
147
|
+
.replace(/\/{2,}/g, '/')
|
|
148
|
+
.replace(/^\/+|\/+$/g, '');
|
|
149
|
+
if (!ns || !path || ns.includes('/') || ns.includes('..') || path.includes('..')) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
return { ns, path };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function registryId(id: string): string {
|
|
156
|
+
return stackIdBase(id);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function tagPseudoId(tag: string, kind?: string): string {
|
|
160
|
+
const raw = tagIdForms(tag)[0]!;
|
|
161
|
+
const parts = splitDumpId(raw);
|
|
162
|
+
return `emi:tag/${kind ?? 'item'}/${parts ? `${parts.ns}/${parts.path}` : raw}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function dumpRelPublicUrl(
|
|
166
|
+
dataset: DatasetRef,
|
|
167
|
+
rel: string | null | undefined,
|
|
168
|
+
): string | undefined {
|
|
169
|
+
const cleaned = typeof rel === 'string' ? cleanDumpRel(rel) : null;
|
|
170
|
+
return cleaned ? dumpPublicUrl(dataset, cleaned) : undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { DUMP_POINTER_TTL, JsonDumpReader } from '@terrafirmagreg-team/dump/lib/dump/json-reader';
|
|
2
|
+
import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
|
|
3
|
+
import {
|
|
4
|
+
createAsyncCache,
|
|
5
|
+
type AsyncCache,
|
|
6
|
+
} from '@terrafirmagreg-team/data/lib/async-cache';
|
|
7
|
+
|
|
8
|
+
export interface DumpReader {
|
|
9
|
+
getJson(path: string, priority?: 'high'): Promise<unknown | undefined>;
|
|
10
|
+
getLang(lang: string): Promise<Record<string, string>>;
|
|
11
|
+
getLangRaw(lang: string): Promise<Record<string, string>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createDatasetCache<A extends unknown[], V>(
|
|
15
|
+
loader: (dataset: DatasetRef, ...rest: A) => Promise<V>,
|
|
16
|
+
keyOf: (...args: [dataset: DatasetRef, ...rest: A]) => string =
|
|
17
|
+
({ id, version }, ...rest) => JSON.stringify([id, version ?? '', ...rest]),
|
|
18
|
+
): AsyncCache<[dataset: DatasetRef, ...rest: A], V> {
|
|
19
|
+
const versioned = createAsyncCache(loader, keyOf);
|
|
20
|
+
const latest = createAsyncCache(loader, keyOf, DUMP_POINTER_TTL);
|
|
21
|
+
const pick = (dataset: DatasetRef) => dataset.version ? versioned : latest;
|
|
22
|
+
const call = (dataset: DatasetRef, ...rest: A) => pick(dataset)(dataset, ...rest);
|
|
23
|
+
call.peek = (dataset: DatasetRef, ...rest: A) =>
|
|
24
|
+
pick(dataset).peek(dataset, ...rest);
|
|
25
|
+
return call;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const dumpReader = createDatasetCache(
|
|
29
|
+
async (dataset: DatasetRef): Promise<DumpReader> => new JsonDumpReader(dataset),
|
|
30
|
+
);
|
|
31
|
+
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { gzipSync } from 'node:zlib';
|
|
2
|
+
import { AwsClient } from 'aws4fetch';
|
|
3
|
+
import {
|
|
4
|
+
initAdvancedSearch,
|
|
5
|
+
type AdvancedIndex,
|
|
6
|
+
} from 'fumadocs-core/search/server';
|
|
7
|
+
import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
|
|
8
|
+
import { dumpReader } from '@terrafirmagreg-team/dump/lib/dump/reader';
|
|
9
|
+
import { parseDumpManifest } from '@terrafirmagreg-team/dump/lib/dump/manifest/parse';
|
|
10
|
+
import { dumpIndexFromManifest } from '@terrafirmagreg-team/dump/lib/dump/mods/index';
|
|
11
|
+
import type { DumpIndex } from '@terrafirmagreg-team/dump/lib/dump/mods/dto';
|
|
12
|
+
import type { DumpManifest } from '@terrafirmagreg-team/dump/lib/dump/manifest/dto';
|
|
13
|
+
|
|
14
|
+
export interface DumpSearchBlob {
|
|
15
|
+
type: 'i18n';
|
|
16
|
+
data: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type DumpSearchSource = {
|
|
20
|
+
getJson(rel: string): Promise<unknown | undefined>;
|
|
21
|
+
getLang(lang: string): Promise<Record<string, string>>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type DumpSearchIndexBuilder = (
|
|
25
|
+
dataset: DatasetRef,
|
|
26
|
+
dump: DumpIndex,
|
|
27
|
+
source: DumpSearchSource,
|
|
28
|
+
) => Promise<(AdvancedIndex & { locale: string })[]>;
|
|
29
|
+
|
|
30
|
+
export async function resolveDumpVersion(
|
|
31
|
+
id: string,
|
|
32
|
+
version: string | undefined,
|
|
33
|
+
): Promise<string | undefined> {
|
|
34
|
+
if (version) return version;
|
|
35
|
+
const reader = await dumpReader({ id });
|
|
36
|
+
const versions = (await reader.getJson('versions.json')) as
|
|
37
|
+
| { latest?: string }
|
|
38
|
+
| undefined;
|
|
39
|
+
return versions?.latest;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function buildDumpSearchBlob(
|
|
43
|
+
id: string,
|
|
44
|
+
version: string | undefined,
|
|
45
|
+
lang: string,
|
|
46
|
+
indexBuilders: DumpSearchIndexBuilder[],
|
|
47
|
+
): Promise<DumpSearchBlob> {
|
|
48
|
+
const ver = await resolveDumpVersion(id, version);
|
|
49
|
+
if (!ver) return { type: 'i18n', data: {} };
|
|
50
|
+
const reader = await dumpReader({ id, version: ver });
|
|
51
|
+
const manifest = parseDumpManifest(
|
|
52
|
+
await reader.getJson('manifest.json'),
|
|
53
|
+
) as DumpManifest | null;
|
|
54
|
+
if (!manifest) return { type: 'i18n', data: {} };
|
|
55
|
+
const dataset = { id, version: ver };
|
|
56
|
+
const dump = dumpIndexFromManifest(manifest, `${id}/${ver}`);
|
|
57
|
+
const indexes = (
|
|
58
|
+
await Promise.all(
|
|
59
|
+
indexBuilders.map((build) => build(dataset, dump, reader)),
|
|
60
|
+
)
|
|
61
|
+
).flat();
|
|
62
|
+
const scoped = indexes.filter((index) => index.locale === lang);
|
|
63
|
+
if (!scoped.length) return { type: 'i18n', data: {} };
|
|
64
|
+
const blob = await initAdvancedSearch({
|
|
65
|
+
indexes: scoped,
|
|
66
|
+
localeFilter: true,
|
|
67
|
+
}).export();
|
|
68
|
+
return { type: 'i18n', data: { [lang]: blob } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const builds = new Map<string, Promise<DumpSearchBlob>>();
|
|
72
|
+
|
|
73
|
+
export function buildDumpSearchBlobCached(
|
|
74
|
+
id: string,
|
|
75
|
+
version: string | undefined,
|
|
76
|
+
lang: string,
|
|
77
|
+
indexBuilders: DumpSearchIndexBuilder[],
|
|
78
|
+
): Promise<DumpSearchBlob> {
|
|
79
|
+
const key = `${id}/${version ?? ''}/${lang}`;
|
|
80
|
+
const cached = builds.get(key);
|
|
81
|
+
if (cached) return cached;
|
|
82
|
+
const p = buildDumpSearchBlob(id, version, lang, indexBuilders);
|
|
83
|
+
p.catch(() => {
|
|
84
|
+
if (builds.get(key) === p) builds.delete(key);
|
|
85
|
+
});
|
|
86
|
+
builds.set(key, p);
|
|
87
|
+
return p;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function s3Client(): AwsClient | undefined {
|
|
91
|
+
const accessKeyId = process.env.DUMP_S3_ACCESS_KEY;
|
|
92
|
+
const secretAccessKey = process.env.DUMP_S3_SECRET_KEY;
|
|
93
|
+
if (!accessKeyId || !secretAccessKey) return undefined;
|
|
94
|
+
return new AwsClient({
|
|
95
|
+
accessKeyId,
|
|
96
|
+
secretAccessKey,
|
|
97
|
+
region: process.env.DUMP_S3_REGION ?? 'auto',
|
|
98
|
+
service: 's3',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function putSearchBlob(
|
|
103
|
+
id: string,
|
|
104
|
+
version: string,
|
|
105
|
+
lang: string,
|
|
106
|
+
blob: unknown,
|
|
107
|
+
): Promise<boolean> {
|
|
108
|
+
const aws = s3Client();
|
|
109
|
+
const endpoint = process.env.DUMP_S3_ENDPOINT;
|
|
110
|
+
const bucket = process.env.DUMP_S3_BUCKET;
|
|
111
|
+
if (!aws || !endpoint || !bucket) return false;
|
|
112
|
+
const key = `${id}/${version}/search/${lang}.json`;
|
|
113
|
+
const url = `${endpoint.replace(/\/+$/, '')}/${bucket}/${key}`;
|
|
114
|
+
const res = await aws.fetch(url, {
|
|
115
|
+
method: 'PUT',
|
|
116
|
+
body: gzipSync(JSON.stringify(blob)),
|
|
117
|
+
headers: {
|
|
118
|
+
'Content-Type': 'application/json',
|
|
119
|
+
'Content-Encoding': 'gzip',
|
|
120
|
+
'Cache-Control': 'public, max-age=3600',
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
return res.ok;
|
|
124
|
+
}
|
|
125
|
+
|
package/src/lib/snbt.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
const WS = /[ \t\n\r]/;
|
|
2
|
+
|
|
3
|
+
const SAFE_KEY = /^[A-Za-z0-9_.\-+]+$/;
|
|
4
|
+
const SAFE_VALUE = /^[A-Za-z_][A-Za-z0-9_.\-+]*$/;
|
|
5
|
+
const LITERAL_WORD = /^(true|false|null|nan|infinity)$/i;
|
|
6
|
+
const MAX_SAFE = 2 ** 53;
|
|
7
|
+
|
|
8
|
+
export function formatNbt(value: unknown): string | undefined {
|
|
9
|
+
if (value === undefined) return undefined;
|
|
10
|
+
return writeNbtValue(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function writeNbtValue(value: unknown): string {
|
|
14
|
+
if (value === null || value === undefined) return 'null';
|
|
15
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
16
|
+
if (typeof value === 'number') return writeNbtNumber(value);
|
|
17
|
+
if (typeof value === 'string') return writeNbtString(value);
|
|
18
|
+
if (Array.isArray(value)) return `[${value.map(writeNbtValue).join(',')}]`;
|
|
19
|
+
if (typeof value === 'object') {
|
|
20
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
|
|
21
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
22
|
+
);
|
|
23
|
+
return `{${entries.map(([k, v]) => `${writeNbtKey(k)}:${writeNbtValue(v)}`).join(',')}}`;
|
|
24
|
+
}
|
|
25
|
+
return 'null';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeNbtKey(key: string): string {
|
|
29
|
+
return SAFE_KEY.test(key) ? key : JSON.stringify(key);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeNbtString(value: string): string {
|
|
33
|
+
return SAFE_VALUE.test(value) && !LITERAL_WORD.test(value)
|
|
34
|
+
? value
|
|
35
|
+
: JSON.stringify(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeNbtNumber(value: number): string {
|
|
39
|
+
if (!Number.isFinite(value)) return '0';
|
|
40
|
+
if (Number.isInteger(value) && Math.abs(value) <= MAX_SAFE) return value.toFixed(0);
|
|
41
|
+
return String(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatNbtPretty(value: unknown): string | undefined {
|
|
45
|
+
if (value === undefined) return undefined;
|
|
46
|
+
return prettyNbtValue(value, 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function prettyNbtValue(value: unknown, depth: number): string {
|
|
50
|
+
const pad = ' '.repeat(depth);
|
|
51
|
+
const padIn = `${pad} `;
|
|
52
|
+
if (value !== null && typeof value === 'object') {
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
if (!value.length) return '[]';
|
|
55
|
+
if (value.every((v) => v === null || typeof v !== 'object')) {
|
|
56
|
+
return `[${value.map(writeNbtValue).join(', ')}]`;
|
|
57
|
+
}
|
|
58
|
+
return `[\n${value
|
|
59
|
+
.map((v) => `${padIn}${prettyNbtValue(v, depth + 1)}`)
|
|
60
|
+
.join(',\n')}\n${pad}]`;
|
|
61
|
+
}
|
|
62
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
|
|
63
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
64
|
+
);
|
|
65
|
+
if (!entries.length) return '{}';
|
|
66
|
+
return `{\n${entries
|
|
67
|
+
.map(([k, v]) => `${padIn}${writeNbtKey(k)}: ${prettyNbtValue(v, depth + 1)}`)
|
|
68
|
+
.join(',\n')}\n${pad}}`;
|
|
69
|
+
}
|
|
70
|
+
return writeNbtValue(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseSnbt(text: string): unknown {
|
|
74
|
+
const src = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
75
|
+
const parser = { src, i: 0 };
|
|
76
|
+
const value = readValue(parser);
|
|
77
|
+
skip(parser);
|
|
78
|
+
if (parser.i < parser.src.length) {
|
|
79
|
+
throw new Error(`SNBT trailing data at ${parser.i}`);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type Parser = { src: string; i: number };
|
|
85
|
+
|
|
86
|
+
function peek(p: Parser): string {
|
|
87
|
+
return p.src[p.i] ?? '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function eof(p: Parser): boolean {
|
|
91
|
+
return p.i >= p.src.length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function skip(p: Parser): void {
|
|
95
|
+
while (!eof(p)) {
|
|
96
|
+
const c = peek(p);
|
|
97
|
+
if (WS.test(c) || c === ',') {
|
|
98
|
+
p.i++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (c === '/' && p.src[p.i + 1] === '/') {
|
|
102
|
+
p.i += 2;
|
|
103
|
+
while (!eof(p) && peek(p) !== '\n') p.i++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readValue(p: Parser): unknown {
|
|
111
|
+
skip(p);
|
|
112
|
+
const c = peek(p);
|
|
113
|
+
if (!c) throw new Error('SNBT unexpected end');
|
|
114
|
+
if (c === '{') return readCompound(p);
|
|
115
|
+
if (c === '[') return readList(p);
|
|
116
|
+
if (c === '"') return readString(p);
|
|
117
|
+
return readBare(p);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readCompound(p: Parser): Record<string, unknown> {
|
|
121
|
+
p.i++;
|
|
122
|
+
const out: Record<string, unknown> = {};
|
|
123
|
+
while (true) {
|
|
124
|
+
skip(p);
|
|
125
|
+
if (peek(p) === '}') {
|
|
126
|
+
p.i++;
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
if (eof(p)) throw new Error('SNBT unclosed compound');
|
|
130
|
+
const key = peek(p) === '"' ? readString(p) : readUnquoted(p);
|
|
131
|
+
skip(p);
|
|
132
|
+
if (peek(p) !== ':') throw new Error(`SNBT expected ':' after key ${key}`);
|
|
133
|
+
p.i++;
|
|
134
|
+
out[key] = readValue(p);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readList(p: Parser): unknown[] {
|
|
139
|
+
p.i++;
|
|
140
|
+
skip(p);
|
|
141
|
+
if (/^[BILbils][ \t]*;/.test(p.src.slice(p.i, p.i + 8))) {
|
|
142
|
+
p.i++;
|
|
143
|
+
skip(p);
|
|
144
|
+
if (peek(p) === ';') p.i++;
|
|
145
|
+
}
|
|
146
|
+
const out: unknown[] = [];
|
|
147
|
+
while (true) {
|
|
148
|
+
skip(p);
|
|
149
|
+
if (peek(p) === ']') {
|
|
150
|
+
p.i++;
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
if (eof(p)) throw new Error('SNBT unclosed list');
|
|
154
|
+
out.push(readValue(p));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readString(p: Parser): string {
|
|
159
|
+
p.i++;
|
|
160
|
+
let out = '';
|
|
161
|
+
while (!eof(p)) {
|
|
162
|
+
const c = peek(p);
|
|
163
|
+
p.i++;
|
|
164
|
+
if (c === '"') return out;
|
|
165
|
+
if (c !== '\\') {
|
|
166
|
+
out += c;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const next = peek(p);
|
|
170
|
+
p.i++;
|
|
171
|
+
switch (next) {
|
|
172
|
+
case 'n':
|
|
173
|
+
out += '\n';
|
|
174
|
+
break;
|
|
175
|
+
case 't':
|
|
176
|
+
out += '\t';
|
|
177
|
+
break;
|
|
178
|
+
case 'r':
|
|
179
|
+
out += '\r';
|
|
180
|
+
break;
|
|
181
|
+
case 'b':
|
|
182
|
+
out += '\b';
|
|
183
|
+
break;
|
|
184
|
+
case 'f':
|
|
185
|
+
out += '\f';
|
|
186
|
+
break;
|
|
187
|
+
case 'u': {
|
|
188
|
+
const hex = p.src.slice(p.i, p.i + 4);
|
|
189
|
+
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
|
|
190
|
+
out += String.fromCharCode(Number.parseInt(hex, 16));
|
|
191
|
+
p.i += 4;
|
|
192
|
+
} else {
|
|
193
|
+
out += 'u';
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case '"':
|
|
198
|
+
case '\\':
|
|
199
|
+
out += next;
|
|
200
|
+
break;
|
|
201
|
+
default:
|
|
202
|
+
out += next;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
throw new Error('SNBT unclosed string');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function readUnquoted(p: Parser): string {
|
|
209
|
+
const start = p.i;
|
|
210
|
+
while (!eof(p)) {
|
|
211
|
+
const c = peek(p);
|
|
212
|
+
if (
|
|
213
|
+
c === ':' ||
|
|
214
|
+
c === '{' ||
|
|
215
|
+
c === '}' ||
|
|
216
|
+
c === '[' ||
|
|
217
|
+
c === ']' ||
|
|
218
|
+
c === ',' ||
|
|
219
|
+
WS.test(c)
|
|
220
|
+
) {
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
p.i++;
|
|
224
|
+
}
|
|
225
|
+
if (p.i === start) throw new Error(`SNBT expected key at ${p.i}`);
|
|
226
|
+
return p.src.slice(start, p.i);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function readBare(p: Parser): string | number | boolean {
|
|
230
|
+
const raw = readUnquoted(p);
|
|
231
|
+
if (raw === 'true') return true;
|
|
232
|
+
if (raw === 'false') return false;
|
|
233
|
+
const num = parseSnbtNumber(raw);
|
|
234
|
+
return num === undefined ? raw : num;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const NUM = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?[bBsSlLfFdD]?$/;
|
|
238
|
+
|
|
239
|
+
function parseSnbtNumber(raw: string): number | undefined {
|
|
240
|
+
if (!NUM.test(raw)) return undefined;
|
|
241
|
+
const last = raw[raw.length - 1];
|
|
242
|
+
const core =
|
|
243
|
+
last && /[bBsSlLfFdD]/.test(last) && !/\d/.test(last) ? raw.slice(0, -1) : raw;
|
|
244
|
+
const value = Number(core);
|
|
245
|
+
return Number.isFinite(value) ? value : undefined;
|
|
246
|
+
}
|
|
247
|
+
|
package/README.md
DELETED