@james-pre/mc-admin 0.1.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.
Files changed (49) hide show
  1. package/LICENSE.md +675 -0
  2. package/README.md +15 -0
  3. package/dist/buffers.d.ts +1 -0
  4. package/dist/buffers.js +1 -0
  5. package/dist/cli.d.ts +6 -0
  6. package/dist/cli.js +110 -0
  7. package/dist/common/buffers.d.ts +8 -0
  8. package/dist/common/buffers.js +47 -0
  9. package/dist/common/chunk.d.ts +41 -0
  10. package/dist/common/chunk.js +31 -0
  11. package/dist/common/index.d.ts +7 -0
  12. package/dist/common/index.js +7 -0
  13. package/dist/common/level.d.ts +21 -0
  14. package/dist/common/level.js +16 -0
  15. package/dist/common/log.d.ts +13 -0
  16. package/dist/common/log.js +10 -0
  17. package/dist/common/nbt.d.ts +85 -0
  18. package/dist/common/nbt.js +179 -0
  19. package/dist/common/rcon.d.ts +58 -0
  20. package/dist/common/rcon.js +154 -0
  21. package/dist/common/region.d.ts +52 -0
  22. package/dist/common/region.js +128 -0
  23. package/dist/common/snbt.d.ts +98 -0
  24. package/dist/common/snbt.js +332 -0
  25. package/dist/common/tsconfig.tsbuildinfo +1 -0
  26. package/dist/config.d.ts +20 -0
  27. package/dist/config.js +12 -0
  28. package/dist/index.d.ts +10 -0
  29. package/dist/index.js +10 -0
  30. package/dist/level.d.ts +53 -0
  31. package/dist/level.js +136 -0
  32. package/dist/log.d.ts +18 -0
  33. package/dist/log.js +137 -0
  34. package/dist/main.d.ts +2 -0
  35. package/dist/main.js +9 -0
  36. package/dist/nbt.d.ts +1 -0
  37. package/dist/nbt.js +1 -0
  38. package/dist/prune.d.ts +85 -0
  39. package/dist/prune.js +201 -0
  40. package/dist/rcon.d.ts +12 -0
  41. package/dist/rcon.js +19 -0
  42. package/dist/region.d.ts +1 -0
  43. package/dist/region.js +1 -0
  44. package/dist/snbt.d.ts +14 -0
  45. package/dist/snbt.js +63 -0
  46. package/dist/tsconfig.tsbuildinfo +1 -0
  47. package/dist/utils.d.ts +9 -0
  48. package/dist/utils.js +52 -0
  49. package/package.json +68 -0
package/dist/log.js ADDED
@@ -0,0 +1,137 @@
1
+ import { existsSync, watch } from 'node:fs';
2
+ import { open, stat } from 'node:fs/promises';
3
+ import { basename, dirname } from 'node:path';
4
+ import { toLines } from './common/buffers.js';
5
+ import { parse } from './common/log.js';
6
+ import { styleText } from 'node:util';
7
+ export * from './common/log.js';
8
+ const blockSize = 64 * 1024;
9
+ /** The offset the last `count` lines of a file begin at. */
10
+ async function backfillOffset(handle, size, count) {
11
+ if (count <= 0)
12
+ return size;
13
+ const block = new Uint8Array(blockSize);
14
+ let end = size, found = 0;
15
+ while (end > 0) {
16
+ const start = Math.max(0, end - block.length);
17
+ const { bytesRead } = await handle.read(block, 0, end - start, start);
18
+ for (let i = bytesRead - 1; i >= 0; i--) {
19
+ // The newline ending the final line doesn't start one.
20
+ if (block[i] !== 0x0a || start + i === size - 1)
21
+ continue;
22
+ if (++found === count)
23
+ return start + i + 1;
24
+ }
25
+ end = start;
26
+ }
27
+ return 0;
28
+ }
29
+ /** Follow a file, emitting bytes as they are appended, across rotation and truncation. */
30
+ export function follow(path, options = {}) {
31
+ const { backfill = 0, interval = 2000, signal, onError } = options;
32
+ const block = new Uint8Array(blockSize);
33
+ let handle = null, inode = null, position = 0, stopped = false, watcher = null, wake = Promise.withResolvers();
34
+ let catchUp = existsSync(path);
35
+ function notify() {
36
+ wake.resolve();
37
+ wake = Promise.withResolvers();
38
+ }
39
+ function attachWatcher() {
40
+ if (watcher || stopped)
41
+ return;
42
+ try {
43
+ watcher = watch(dirname(path), (_event, name) => {
44
+ if (!name || name === basename(path))
45
+ notify();
46
+ });
47
+ watcher.on('error', () => { });
48
+ }
49
+ catch {
50
+ // No directory yet; the poll finds the file once it appears.
51
+ }
52
+ }
53
+ const timer = setInterval(notify, interval).unref();
54
+ function stop() {
55
+ stopped = true;
56
+ watcher?.close();
57
+ watcher = null;
58
+ clearInterval(timer);
59
+ void handle?.close().catch(() => { });
60
+ handle = null;
61
+ notify();
62
+ }
63
+ signal?.addEventListener('abort', stop, { once: true });
64
+ /** Open the file, reopening it when it has been rotated or truncated. */
65
+ async function reopen() {
66
+ const stats = await stat(path).catch(() => null);
67
+ if (!stats)
68
+ return;
69
+ if (handle && stats.ino === inode && stats.size >= position)
70
+ return;
71
+ await handle?.close().catch(() => { });
72
+ handle = await open(path, 'r');
73
+ attachWatcher();
74
+ const opened = await handle.stat();
75
+ inode = opened.ino;
76
+ position = catchUp ? await backfillOffset(handle, opened.size, backfill) : 0;
77
+ catchUp = false;
78
+ }
79
+ attachWatcher();
80
+ return new ReadableStream({
81
+ async pull(controller) {
82
+ while (!stopped) {
83
+ try {
84
+ await reopen();
85
+ if (handle) {
86
+ const { bytesRead } = await handle.read(block, 0, block.length, position);
87
+ if (bytesRead > 0) {
88
+ position += bytesRead;
89
+ controller.enqueue(block.slice(0, bytesRead));
90
+ return;
91
+ }
92
+ }
93
+ }
94
+ catch (error) {
95
+ if (!onError)
96
+ throw error;
97
+ onError(error);
98
+ }
99
+ await wake.promise;
100
+ }
101
+ controller.close();
102
+ },
103
+ cancel: stop,
104
+ });
105
+ }
106
+ /** Follow a file, emitting each line appended to it. */
107
+ export function tail(path, options) {
108
+ return toLines(follow(path, options));
109
+ }
110
+ export const levelColors = {
111
+ TRACE: 'gray',
112
+ DEBUG: 'magenta',
113
+ INFO: 'cyan',
114
+ WARN: 'yellowBright',
115
+ ERROR: 'red',
116
+ FATAL: 'redBright',
117
+ };
118
+ // Command feedback broadcast, `[Rcon: ...]`.
119
+ const feedbackPattern = /^\[\w+:\s.*\]$/;
120
+ // Chat, e.g. `<Notch> hi`. The trailing `>` and space keep this from matching `<--[HERE]`.
121
+ const chatPattern = /^(<\w+>)(\s.*)$/;
122
+ function formatMessage(message) {
123
+ if (feedbackPattern.test(message))
124
+ return styleText(['italic', 'dim'], message);
125
+ const chat = chatPattern.exec(message);
126
+ if (chat)
127
+ return styleText('bold', chat[1]) + chat[2];
128
+ return message;
129
+ }
130
+ export function format(line) {
131
+ const parsed = typeof line == 'object' && line !== null ? line : parse(line);
132
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
133
+ if (!parsed)
134
+ return String(line);
135
+ const { timestamp, thread, level, message } = parsed;
136
+ return [styleText('gray', `[${timestamp}]`), styleText(levelColors[level], `[${thread}/${level}]:`), formatMessage(message)].join(' ');
137
+ }
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import * as io from 'ioium/node';
3
+ import cli from './cli.js';
4
+ try {
5
+ await cli.parseAsync();
6
+ }
7
+ catch (e) {
8
+ io.exit(e);
9
+ }
package/dist/nbt.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './common/nbt.js';
package/dist/nbt.js ADDED
@@ -0,0 +1 @@
1
+ export * from './common/nbt.js';
@@ -0,0 +1,85 @@
1
+ import { Dimension, type RegionFile } from './level.js';
2
+ import { EventEmitter } from 'node:events';
3
+ export type ExcludeReason = 'excluded' | 'unreadable' | 'inhabited' | 'conflict';
4
+ export interface MoveInfo {
5
+ src: string;
6
+ dest: string;
7
+ /** The destination already holds an identical file, so the source is dropped rather than moved. */
8
+ duplicate: boolean;
9
+ /** The destination holds a different file that this one replaces. */
10
+ overwrite: boolean;
11
+ }
12
+ export interface Region extends RegionFile {
13
+ dimension: Dimension;
14
+ /** The size of the region's files in bytes */
15
+ size: number;
16
+ /** How many chunks the region stores. */
17
+ chunks: number;
18
+ /** How many chunks that could not be parsed. */
19
+ unreadable: number;
20
+ /** The longest players have spent in any one chunk. */
21
+ inhabitedTicks: bigint;
22
+ keep?: ExcludeReason | null;
23
+ moves: MoveInfo[];
24
+ deletes: string[];
25
+ error?: Error;
26
+ }
27
+ export interface ExecuteOptions {
28
+ /** How many regions operate on at once. */
29
+ concurrency?: number;
30
+ /** Whether the first failure stops the transaction, leaving the remaining regions untouched. */
31
+ atomic?: boolean;
32
+ }
33
+ export declare const conflictModes: readonly ["throw", "exclude", "overwrite", "preserve"];
34
+ export type ConflictMode = (typeof conflictModes)[number];
35
+ export interface PrepareOptions extends ExecuteOptions {
36
+ /** Keep regions where players have spent at least this long in a chunk, in ticks. */
37
+ threshold: bigint;
38
+ /** Region coordinates to keep regardless of other constraints, as `<x>,<z>`, keyed by dimension id. */
39
+ exclude?: Readonly<Record<string, readonly string[]>>;
40
+ /** Delete the files instead of moving them. */
41
+ delete?: boolean;
42
+ /**
43
+ * Where moved files go.
44
+ * Relative paths are to the region directory, absolute paths will mirror the world directory layout
45
+ * @default 'old'
46
+ */
47
+ into?: string | null;
48
+ /**
49
+ * How to handle conflicts when moving regions
50
+ * - throw: emit an error for the given region, stopping the transaction when `atomic` is also set
51
+ * - exclude: the region will not be pruned
52
+ * - overwrite: the old region will be overwritten with the new one
53
+ * - preserve: the old region will be kept and the new one will be deleted (you probably do not want this)
54
+ * @default 'throw'
55
+ */
56
+ conflicting?: ConflictMode;
57
+ }
58
+ export interface Result {
59
+ pruned: Region[];
60
+ /** Bytes of region data removed. */
61
+ freed: number;
62
+ failed: {
63
+ region: Region;
64
+ error: Error;
65
+ }[];
66
+ /** Regions left untouched because an earlier failure stopped an atomic transaction. */
67
+ skipped: Region[];
68
+ }
69
+ export declare class Transaction extends EventEmitter<{
70
+ prepare_error: [error: Error, file: RegionFile];
71
+ prepare_exclude: [reason: ExcludeReason, region: Region];
72
+ execute_error: [error: Error, region: Region];
73
+ }> {
74
+ #private;
75
+ readonly path: string;
76
+ readonly regions: Region[];
77
+ /** *All* of the regions, including excluded ones. */
78
+ readonly allRegions: Region[];
79
+ constructor(path: string);
80
+ get pruneSize(): number;
81
+ get isEmpty(): boolean;
82
+ get isPrepared(): boolean;
83
+ prepare(options: PrepareOptions): Promise<void>;
84
+ execute(options?: ExecuteOptions): Promise<Result>;
85
+ }
package/dist/prune.js ADDED
@@ -0,0 +1,201 @@
1
+ import * as fs from 'node:fs';
2
+ import { dirname, isAbsolute, join, relative } from 'node:path';
3
+ import { get, TagType } from './common/nbt.js';
4
+ import { Dimension, isLevel, Level, normalizeId } from './level.js';
5
+ import { Region as RegionData } from './region.js';
6
+ import { concurrent, exists, filesIdentical, moveFile } from './utils.js';
7
+ import { EventEmitter } from 'node:events';
8
+ export const conflictModes = ['throw', 'exclude', 'overwrite', 'preserve'];
9
+ export class Transaction extends EventEmitter {
10
+ path;
11
+ regions = [];
12
+ /** *All* of the regions, including excluded ones. */
13
+ allRegions = [];
14
+ constructor(path) {
15
+ super({ captureRejections: true });
16
+ this.path = path;
17
+ }
18
+ get pruneSize() {
19
+ return this.regions.reduce((sum, r) => sum + r.size, 0);
20
+ }
21
+ get isEmpty() {
22
+ return !this.regions.length;
23
+ }
24
+ #started = false;
25
+ #prepared = false;
26
+ get isPrepared() {
27
+ return this.#prepared;
28
+ }
29
+ async prepare(options) {
30
+ if (this.#started)
31
+ throw new Error('Transaction already prepared');
32
+ this.#started = true;
33
+ const isForLevel = await isLevel(this.path);
34
+ const dimensions = isForLevel
35
+ ? await new Level(this.path).dimensions()
36
+ : (await exists(join(this.path, 'region')))
37
+ ? [Dimension.at(this.path)]
38
+ : [];
39
+ if (!dimensions.length)
40
+ throw new Error('Transaction path does not match any dimensions');
41
+ const into = options.into ?? 'old';
42
+ for (const dimension of dimensions) {
43
+ const excluded = new Set(options.exclude?.[normalizeId(dimension.id)]);
44
+ await concurrent(await dimension.regionFiles(), options.concurrency ?? 4, async (file) => {
45
+ try {
46
+ const region = new RegionData(await fs.promises.readFile(file.path), file);
47
+ const stored = Array.from(region.entries()).length;
48
+ let readable = 0, inhabitedTicks = 0n;
49
+ for await (const chunk of region.chunks()) {
50
+ readable++;
51
+ const tag = get(chunk.tag, 'InhabitedTime');
52
+ if (tag?.type === TagType.Long && tag.value > inhabitedTicks)
53
+ inhabitedTicks = tag.value;
54
+ }
55
+ const unreadable = stored - readable;
56
+ let keep = excluded.has(`${region.file.x},${region.file.z}`)
57
+ ? 'excluded'
58
+ : unreadable
59
+ ? 'unreadable'
60
+ : inhabitedTicks >= options.threshold
61
+ ? 'inhabited'
62
+ : null;
63
+ const files = await dimension.regionFilesAt(region.file.x, region.file.z);
64
+ const txRegion = {
65
+ ...region.file,
66
+ dimension,
67
+ size: 0,
68
+ chunks: stored,
69
+ unreadable,
70
+ inhabitedTicks,
71
+ keep,
72
+ moves: [],
73
+ deletes: [],
74
+ };
75
+ this.allRegions.push(txRegion);
76
+ if (keep) {
77
+ this.emit('prepare_exclude', keep, txRegion);
78
+ return;
79
+ }
80
+ await Promise.all(files.map(async (file) => {
81
+ await fs.promises.access(dirname(file.path), fs.constants.W_OK | fs.constants.X_OK);
82
+ const { size } = await fs.promises.stat(file.path);
83
+ if (options.delete) {
84
+ txRegion.deletes.push(file.path);
85
+ txRegion.size += size;
86
+ }
87
+ else {
88
+ const dest = isAbsolute(into)
89
+ ? join(into, isForLevel ? relative(dimension.level, dimension.path) : '', file.kind, file.name)
90
+ : join(dimension.path, file.kind, into, file.name);
91
+ try {
92
+ if (await filesIdentical(file.path, dest)) {
93
+ txRegion.moves.push({ src: file.path, dest, duplicate: true, overwrite: false });
94
+ txRegion.size += size;
95
+ return;
96
+ }
97
+ switch (options.conflicting) {
98
+ case 'exclude':
99
+ keep = 'conflict';
100
+ break;
101
+ case 'preserve':
102
+ txRegion.deletes.push(file.path);
103
+ txRegion.size += size;
104
+ break;
105
+ case 'overwrite':
106
+ txRegion.moves.push({ src: file.path, dest, duplicate: false, overwrite: true });
107
+ txRegion.size += size;
108
+ break;
109
+ case 'throw':
110
+ default:
111
+ throw new Error(`destination exists and differs: ${dest}`);
112
+ }
113
+ }
114
+ catch (e) {
115
+ if (e.code !== 'ENOENT')
116
+ throw e;
117
+ txRegion.size += size;
118
+ txRegion.moves.push({ src: file.path, dest, duplicate: false, overwrite: false });
119
+ }
120
+ }
121
+ }));
122
+ if (keep) {
123
+ Object.assign(txRegion, { keep, size: 0, moves: [], deletes: [] });
124
+ this.emit('prepare_exclude', keep, txRegion);
125
+ return;
126
+ }
127
+ this.regions.push(txRegion);
128
+ }
129
+ catch (e) {
130
+ if (options.atomic)
131
+ throw e;
132
+ this.emit('prepare_error', e, file);
133
+ }
134
+ });
135
+ }
136
+ const order = new Map(dimensions.map((dimension, index) => [dimension, index]));
137
+ const compare = (a, b) => order.get(a.dimension) - order.get(b.dimension) || a.x - b.x || a.z - b.z;
138
+ this.regions.sort(compare);
139
+ this.allRegions.sort(compare);
140
+ this.#prepared = true;
141
+ }
142
+ /**
143
+ * Apply one region's plan with rollback on failure
144
+ */
145
+ async #executeRegion(region) {
146
+ const moved = [];
147
+ try {
148
+ for (const move of region.moves) {
149
+ if (move.duplicate)
150
+ continue;
151
+ await moveFile(move.src, move.dest, move.overwrite);
152
+ moved.push(move);
153
+ }
154
+ }
155
+ catch (error) {
156
+ for (const move of moved.reverse())
157
+ await moveFile(move.dest, move.src, true).catch(() => { });
158
+ throw error;
159
+ }
160
+ for (const move of region.moves)
161
+ if (move.duplicate)
162
+ await fs.promises.rm(move.src);
163
+ for (const path of region.deletes)
164
+ await fs.promises.rm(path);
165
+ }
166
+ async execute(options = {}) {
167
+ if (!this.#prepared)
168
+ throw new Error('Transaction is not prepared');
169
+ const result = { pruned: [], freed: 0, failed: [], skipped: [] };
170
+ let stopped = false;
171
+ const errors = await concurrent(this.regions, options.concurrency ?? 4, async (region) => {
172
+ if (stopped)
173
+ return 'skipped';
174
+ try {
175
+ await this.#executeRegion(region);
176
+ return null;
177
+ }
178
+ catch (error) {
179
+ if (options.atomic)
180
+ stopped = true;
181
+ return error;
182
+ }
183
+ });
184
+ for (const [index, error] of errors.entries()) {
185
+ const region = this.regions[index];
186
+ if (error === 'skipped') {
187
+ result.skipped.push(region);
188
+ }
189
+ else if (error) {
190
+ region.error = error;
191
+ this.emit('execute_error', error, region);
192
+ result.failed.push({ region, error });
193
+ }
194
+ else {
195
+ result.pruned.push(region);
196
+ result.freed += region.size;
197
+ }
198
+ }
199
+ return result;
200
+ }
201
+ }
package/dist/rcon.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export * from './common/rcon.js';
2
+ import { type Socket, type TcpSocketConnectOpts } from 'node:net';
3
+ import { Connection, type ConnectionOptions } from './common/rcon.js';
4
+ export interface ConnectOptions extends ConnectionOptions, TcpSocketConnectOpts {
5
+ }
6
+ /**
7
+ * Connect to an RCON server.
8
+ * You will need to authenticate before sending any commands!
9
+ */
10
+ export declare function connect(options: ConnectOptions): Promise<Connection & {
11
+ socket: Socket;
12
+ }>;
package/dist/rcon.js ADDED
@@ -0,0 +1,19 @@
1
+ export * from './common/rcon.js';
2
+ import { connect as connectSocket } from 'node:net';
3
+ import { Connection } from './common/rcon.js';
4
+ import { Duplex } from 'node:stream';
5
+ /**
6
+ * Connect to an RCON server.
7
+ * You will need to authenticate before sending any commands!
8
+ */
9
+ export async function connect(options) {
10
+ const socket = connectSocket(options);
11
+ socket.setNoDelay(true);
12
+ const opened = Promise.withResolvers();
13
+ socket.once('error', opened.reject);
14
+ socket.once('connect', opened.resolve);
15
+ await opened.promise;
16
+ socket.removeAllListeners('error');
17
+ const connection = new Connection(Duplex.toWeb(socket), options);
18
+ return Object.assign(connection, { socket });
19
+ }
@@ -0,0 +1 @@
1
+ export * from './common/region.js';
package/dist/region.js ADDED
@@ -0,0 +1 @@
1
+ export * from './common/region.js';
package/dist/snbt.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export * from './common/snbt.js';
2
+ import { type InspectColor } from 'node:util';
3
+ import { type Tag } from './common/nbt.js';
4
+ export declare const colors: Record<'name' | 'string' | 'number' | 'suffix', InspectColor>;
5
+ /**
6
+ * A string is always enclosed by double or single quotes.
7
+ * If the string does not contain any quote marks, double quotes are used.
8
+ * If the string contains a double quote then single quotes are used, and vice versa.
9
+ * If the string contains both then the opposite of the first instance of either in the string is used
10
+ * (e.g. if a " appears before a ' then the string will be enclosed in single quotes)
11
+ * @see https://minecraft.wiki/w/NBT_format#Conversion_to_SNBT
12
+ */
13
+ export declare function escapeString(value: string): string;
14
+ export declare function format(tag: Tag): string;
package/dist/snbt.js ADDED
@@ -0,0 +1,63 @@
1
+ export * from './common/snbt.js';
2
+ import { styleText } from 'node:util';
3
+ import { TagType } from './common/nbt.js';
4
+ import { arrayMarkers, tagSuffixes, escapes } from './common/snbt.js';
5
+ export const colors = {
6
+ name: 'cyan',
7
+ string: 'green',
8
+ number: 'yellow',
9
+ suffix: 'red',
10
+ };
11
+ /** `escapes` read backwards: the letter to write for each character that needs one. */
12
+ const escaped = new Map(Object.entries(escapes)
13
+ .filter(([letter]) => letter != 's')
14
+ .map(([letter, char]) => [char, letter]));
15
+ /**
16
+ * A string is always enclosed by double or single quotes.
17
+ * If the string does not contain any quote marks, double quotes are used.
18
+ * If the string contains a double quote then single quotes are used, and vice versa.
19
+ * If the string contains both then the opposite of the first instance of either in the string is used
20
+ * (e.g. if a " appears before a ' then the string will be enclosed in single quotes)
21
+ * @see https://minecraft.wiki/w/NBT_format#Conversion_to_SNBT
22
+ */
23
+ export function escapeString(value) {
24
+ const double = value.indexOf('"'), single = value.indexOf("'");
25
+ const quote = double == -1 ? '"' : single == -1 ? "'" : double < single ? "'" : '"';
26
+ let out = quote;
27
+ for (const char of value) {
28
+ if (char == '\\' || char == quote) {
29
+ out += '\\' + char;
30
+ continue;
31
+ }
32
+ const escape = escaped.get(char);
33
+ if (escape) {
34
+ out += '\\' + escape;
35
+ continue;
36
+ }
37
+ const code = char.codePointAt(0);
38
+ out += code < 0x20 || (code >= 0x7f && code <= 0x9f) ? '\\u' + code.toString(16).padStart(4, '0') : char;
39
+ }
40
+ return out + quote;
41
+ }
42
+ const isArrayTag = (tag) => tag.type in arrayMarkers;
43
+ function num(value, suffix) {
44
+ const decimal = suffix === 'f' || suffix === 'd';
45
+ const text = decimal && Number.isInteger(value) ? Number(value).toFixed(1) : String(value);
46
+ return styleText(colors.number, text) + (suffix ? styleText(colors.suffix, suffix) : '');
47
+ }
48
+ export function format(tag) {
49
+ if (tag.type == TagType.String)
50
+ return styleText(colors.string, escapeString(tag.value));
51
+ if (tag.type == TagType.Compound) {
52
+ const entries = Array.from(tag.value).map(([key, child]) => `${styleText(colors.name, key)}:${format(child)}`);
53
+ return `{${entries.join(',')}}`;
54
+ }
55
+ if (tag.type == TagType.List)
56
+ return `[${tag.value.map(v => format(v)).join(',')}]`;
57
+ if (isArrayTag(tag)) {
58
+ const [marker, suffix] = arrayMarkers[tag.type];
59
+ const items = [...tag.value].map(item => num(item, suffix));
60
+ return `[${styleText(colors.suffix, marker)};${items.join(',')}]`;
61
+ }
62
+ return num(tag.value, tagSuffixes[tag.type] ?? '');
63
+ }