@serverkgg/bridge 0.0.1 → 0.0.2
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 +10 -3
- package/src/cli/main.ts +11 -1
- package/src/cli/schema.ts +62 -0
- package/src/cli/validate.ts +119 -36
- package/src/context/bridgeContext.ts +11 -1
- package/src/core/bridgeAction.ts +13 -0
- package/src/core/bridgeFailure.ts +16 -0
- package/src/core/bridgeKind.ts +2 -0
- package/src/core/bridgeNetError.ts +10 -0
- package/src/core/index.ts +2 -0
- package/src/guides/blocks.ts +167 -0
- package/src/guides/compile.ts +193 -0
- package/src/guides/compiled.ts +28 -0
- package/src/guides/index.ts +6 -0
- package/src/guides/markdown.test.ts +374 -0
- package/src/guides/markdown.ts +769 -0
- package/src/guides/variables.ts +52 -0
- package/src/guides/version.ts +1 -0
- package/src/main.ts +2 -1
- package/src/manifest/assets.ts +4 -4
- package/src/manifest/guide.ts +23 -18
- package/src/manifest/index.ts +3 -2
- package/src/manifest/jsonSchema.ts +16 -0
- package/src/manifest/localized.ts +7 -0
- package/src/manifest/manifest.test.ts +366 -0
- package/src/manifest/manifest.ts +354 -120
- package/src/manifest/primitives.ts +63 -0
- package/src/manifest/source.ts +5 -4
- package/src/modules/bridge.ts +19 -3
- package/src/modules/bridgeDriver.ts +4 -0
- package/src/modules/bridgeModule.ts +22 -24
- package/src/panel/bridgeAccess.ts +3 -0
- package/src/panel/bridgePanel.ts +6 -0
- package/src/panel/index.ts +1 -0
- package/src/protocol/bridgeCatalog.ts +56 -0
- package/src/protocol/bridgeEgress.ts +53 -0
- package/src/protocol/bridgeFrame.ts +17 -1
- package/src/protocol/bridgeLine.ts +41 -6
- package/src/protocol/bridgeManifest.ts +15 -1
- package/src/protocol/bridgeVersion.ts +1 -1
- package/src/protocol/index.ts +3 -0
- package/src/runtime/bridgeContext.ts +29 -9
- package/src/runtime/bridgeDriver.ts +1 -0
- package/src/runtime/bridgeEgress.ts +360 -0
- package/src/runtime/bridgeExec.ts +15 -5
- package/src/runtime/bridgeFiles.ts +58 -21
- package/src/runtime/bridgeLog.ts +26 -3
- package/src/runtime/bridgeNet.ts +75 -22
- package/src/runtime/bridgeRegistry.ts +51 -6
- package/src/runtime/bridgeServer.ts +48 -1
- package/src/runtime/bridgeSupervisor.ts +6 -6
- package/src/runtime/index.ts +0 -17
- package/src/runtime/serve.ts +44 -3
- package/src/terminal/bridgeTerminalArg.ts +11 -0
- package/src/terminal/bridgeTerminalCommand.ts +11 -0
- package/src/terminal/bridgeTerminalLevel.ts +6 -0
- package/src/terminal/bridgeTerminalRule.ts +8 -0
- package/src/terminal/index.ts +4 -0
- package/src/manifest/checks.ts +0 -50
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { verifyAsset } from "../manifest/assets";
|
|
2
|
+
import type { GuideEntry, GuideSurface, GuideTag } from "../manifest/guide";
|
|
3
|
+
import type { LocalizedText } from "../manifest/localized";
|
|
4
|
+
import type { GuidePackage, Manifest } from "../manifest/manifest";
|
|
5
|
+
import type { PackageSource } from "../manifest/source";
|
|
6
|
+
import type { GuideBlock, GuideLeafBlock } from "./blocks";
|
|
7
|
+
import { type CompiledGuide, type CompiledGuideTag, compiledGuideSchema } from "./compiled";
|
|
8
|
+
import { type ParsedBlock, type ParsedLeafBlock, parseGuideMarkdown } from "./markdown";
|
|
9
|
+
|
|
10
|
+
export interface GuideImageAsset {
|
|
11
|
+
path: string;
|
|
12
|
+
bytes: Uint8Array;
|
|
13
|
+
mediaType: string;
|
|
14
|
+
width: number | null;
|
|
15
|
+
height: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DraftGuide {
|
|
19
|
+
id: string;
|
|
20
|
+
title: LocalizedText;
|
|
21
|
+
summary: LocalizedText;
|
|
22
|
+
tags: CompiledGuideTag[];
|
|
23
|
+
surfaces: GuideSurface[];
|
|
24
|
+
videoId: string | null;
|
|
25
|
+
body: {
|
|
26
|
+
ar: ParsedBlock[];
|
|
27
|
+
en: ParsedBlock[];
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GuidePack {
|
|
32
|
+
drafts: DraftGuide[];
|
|
33
|
+
files: string[];
|
|
34
|
+
images: GuideImageAsset[];
|
|
35
|
+
commands: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const compileTags = (guideTags: GuideTag[], tags: string[]): CompiledGuideTag[] => {
|
|
39
|
+
return tags.map((tag) => {
|
|
40
|
+
const order = guideTags.findIndex((declared) => declared.id === tag);
|
|
41
|
+
const declared = guideTags[order];
|
|
42
|
+
|
|
43
|
+
if (!declared) {
|
|
44
|
+
throw new Error(`tag "${tag}" is not declared in guideTags`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
id: declared.id,
|
|
49
|
+
name: declared.name,
|
|
50
|
+
order,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const compileGuides = (source: PackageSource, guideTags: GuideTag[], entries: GuideEntry[]): GuidePack => {
|
|
56
|
+
const drafts: DraftGuide[] = [];
|
|
57
|
+
const files: string[] = [];
|
|
58
|
+
const commands: string[] = [];
|
|
59
|
+
const imageByPath = new Map<string, GuideImageAsset>();
|
|
60
|
+
|
|
61
|
+
const readBody = (path: string) => {
|
|
62
|
+
const body = source.read(path);
|
|
63
|
+
|
|
64
|
+
if (!body) {
|
|
65
|
+
throw new Error(`missing guide body ${path}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
files.push(path);
|
|
69
|
+
|
|
70
|
+
const parsed = parseGuideMarkdown(path, new TextDecoder().decode(body));
|
|
71
|
+
|
|
72
|
+
commands.push(...parsed.commands);
|
|
73
|
+
|
|
74
|
+
for (const imagePath of parsed.images) {
|
|
75
|
+
if (imageByPath.has(imagePath)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const imageBytes = source.read(imagePath);
|
|
80
|
+
|
|
81
|
+
if (!imageBytes) {
|
|
82
|
+
throw new Error(`missing guide image ${imagePath}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const verified = verifyAsset(imagePath, imageBytes);
|
|
86
|
+
|
|
87
|
+
imageByPath.set(imagePath, {
|
|
88
|
+
path: imagePath,
|
|
89
|
+
bytes: imageBytes,
|
|
90
|
+
mediaType: verified.mediaType,
|
|
91
|
+
width: verified.dimensions?.width ?? null,
|
|
92
|
+
height: verified.dimensions?.height ?? null,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return parsed.blocks;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
drafts.push({
|
|
101
|
+
id: entry.id,
|
|
102
|
+
title: entry.title,
|
|
103
|
+
summary: entry.summary,
|
|
104
|
+
tags: compileTags(guideTags, entry.tags),
|
|
105
|
+
surfaces: entry.surfaces,
|
|
106
|
+
videoId: entry.video ?? null,
|
|
107
|
+
body: {
|
|
108
|
+
ar: readBody(entry.bodyPath.ar),
|
|
109
|
+
en: readBody(entry.bodyPath.en),
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
drafts,
|
|
116
|
+
files: [
|
|
117
|
+
...files,
|
|
118
|
+
...imageByPath.keys(),
|
|
119
|
+
],
|
|
120
|
+
images: [
|
|
121
|
+
...imageByPath.values(),
|
|
122
|
+
],
|
|
123
|
+
commands,
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const readGuidePack = (source: PackageSource, manifest: Manifest): GuidePack | null => {
|
|
128
|
+
if (manifest.guides.length === 0) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return compileGuides(source, manifest.guideTags, manifest.guides);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const readGuidePackage = (source: PackageSource, document: GuidePackage): GuidePack => {
|
|
136
|
+
return compileGuides(source, document.guideTags, document.guides);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export interface ResolvedGuideImage {
|
|
140
|
+
url: string;
|
|
141
|
+
width: number | null;
|
|
142
|
+
height: number | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const resolveGuideImages = (
|
|
146
|
+
drafts: DraftGuide[],
|
|
147
|
+
resolved: Map<string, ResolvedGuideImage>,
|
|
148
|
+
): CompiledGuide[] => {
|
|
149
|
+
const resolveLeaf = (block: ParsedLeafBlock): GuideLeafBlock => {
|
|
150
|
+
if (block.type !== "image") {
|
|
151
|
+
return block;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const image = resolved.get(block.path);
|
|
155
|
+
|
|
156
|
+
if (!image) {
|
|
157
|
+
throw new Error(`unresolved guide image ${block.path}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
type: "image",
|
|
162
|
+
url: image.url,
|
|
163
|
+
alt: block.alt,
|
|
164
|
+
width: image.width,
|
|
165
|
+
height: image.height,
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const resolveBlocks = (blocks: ParsedBlock[]): GuideBlock[] => {
|
|
170
|
+
return blocks.map((block) => {
|
|
171
|
+
if (block.type !== "conditional") {
|
|
172
|
+
return resolveLeaf(block);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
type: "conditional",
|
|
177
|
+
key: block.key,
|
|
178
|
+
when: block.when.map(resolveLeaf),
|
|
179
|
+
otherwise: block.otherwise.map(resolveLeaf),
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
return drafts.map((draft) => {
|
|
185
|
+
return compiledGuideSchema.parse({
|
|
186
|
+
...draft,
|
|
187
|
+
body: {
|
|
188
|
+
ar: resolveBlocks(draft.body.ar),
|
|
189
|
+
en: resolveBlocks(draft.body.en),
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { GuideSurface, guideTagSchema, zYoutubeId } from "../manifest/guide";
|
|
3
|
+
import { localizedTextSchema } from "../manifest/localized";
|
|
4
|
+
import { zSlug } from "../manifest/primitives";
|
|
5
|
+
import { guideBlockSchema } from "./blocks";
|
|
6
|
+
|
|
7
|
+
export const GUIDE_BODY_MAX_BLOCKS = 400;
|
|
8
|
+
|
|
9
|
+
export const compiledGuideTagSchema = guideTagSchema.extend({
|
|
10
|
+
order: z.int().nonnegative(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type CompiledGuideTag = z.infer<typeof compiledGuideTagSchema>;
|
|
14
|
+
|
|
15
|
+
export const compiledGuideSchema = z.object({
|
|
16
|
+
id: zSlug,
|
|
17
|
+
title: localizedTextSchema,
|
|
18
|
+
summary: localizedTextSchema,
|
|
19
|
+
tags: z.array(compiledGuideTagSchema).max(8).default([]),
|
|
20
|
+
surfaces: z.array(z.enum(GuideSurface)).max(8).default([]),
|
|
21
|
+
videoId: zYoutubeId.nullable(),
|
|
22
|
+
body: z.object({
|
|
23
|
+
ar: z.array(guideBlockSchema).min(1).max(GUIDE_BODY_MAX_BLOCKS),
|
|
24
|
+
en: z.array(guideBlockSchema).min(1).max(GUIDE_BODY_MAX_BLOCKS),
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export type CompiledGuide = z.infer<typeof compiledGuideSchema>;
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { GuideOpenTab } from "./blocks";
|
|
3
|
+
import { parseGuideMarkdown } from "./markdown";
|
|
4
|
+
|
|
5
|
+
const parse = (text: string) => parseGuideMarkdown("guides/test/en.md", text);
|
|
6
|
+
|
|
7
|
+
describe("static blocks", () => {
|
|
8
|
+
test("parses the existing block vocabulary", () => {
|
|
9
|
+
const { blocks } = parse(
|
|
10
|
+
[
|
|
11
|
+
"## Join the game",
|
|
12
|
+
"",
|
|
13
|
+
"Copy the address and paste it in.",
|
|
14
|
+
"",
|
|
15
|
+
"1. Open **Multiplayer**",
|
|
16
|
+
"2. Click **Add Server**",
|
|
17
|
+
"",
|
|
18
|
+
"```txt",
|
|
19
|
+
"say hello",
|
|
20
|
+
"```",
|
|
21
|
+
"",
|
|
22
|
+
"> [!note] The first start takes a moment.",
|
|
23
|
+
].join("\n"),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect(blocks.map((block) => block.type)).toEqual([
|
|
27
|
+
"heading",
|
|
28
|
+
"paragraph",
|
|
29
|
+
"list",
|
|
30
|
+
"code",
|
|
31
|
+
"callout",
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("still rejects what it always rejected", () => {
|
|
36
|
+
expect(() => parse("# Title")).toThrow(/h1 is not supported/);
|
|
37
|
+
expect(() => parse("| a | b |")).toThrow(/tables are not supported/);
|
|
38
|
+
expect(() => parse("<div>hi</div>")).toThrow(/raw HTML/);
|
|
39
|
+
expect(() => parse("---")).toThrow(/horizontal rules/);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("variables", () => {
|
|
44
|
+
test("parses an inline variable", () => {
|
|
45
|
+
const { blocks } = parse("Your address is {{server.address}} — paste it in.");
|
|
46
|
+
|
|
47
|
+
expect(blocks[0]).toEqual({
|
|
48
|
+
type: "paragraph",
|
|
49
|
+
children: [
|
|
50
|
+
{
|
|
51
|
+
type: "text",
|
|
52
|
+
text: "Your address is ",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
type: "variable",
|
|
56
|
+
key: "server.address",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: "text",
|
|
60
|
+
text: " — paste it in.",
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("accepts a panel variable", () => {
|
|
67
|
+
const { blocks } = parse("The motd is {{panel.MOTD}}.");
|
|
68
|
+
|
|
69
|
+
expect(blocks[0]).toMatchObject({
|
|
70
|
+
children: expect.arrayContaining([
|
|
71
|
+
{
|
|
72
|
+
type: "variable",
|
|
73
|
+
key: "panel.MOTD",
|
|
74
|
+
},
|
|
75
|
+
]),
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("rejects an unknown key", () => {
|
|
80
|
+
expect(() => parse("Your address is {{server.adress}}.")).toThrow(/unknown variable "server.adress"/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("rejects a lowercase panel key", () => {
|
|
84
|
+
expect(() => parse("{{panel.motd}} is set.")).toThrow(/unknown variable/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("rejects an unterminated marker", () => {
|
|
88
|
+
expect(() => parse("Your address is {{server.address.")).toThrow(/unterminated \{\{ marker/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rejects a variable inside a link target", () => {
|
|
92
|
+
expect(() => parse("See [your server](/server/{{server.code}}).")).toThrow(
|
|
93
|
+
/variables cannot be used inside a link target/,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("directives", () => {
|
|
99
|
+
test("parses a field", () => {
|
|
100
|
+
const { blocks } = parse("@[field](server.address)");
|
|
101
|
+
|
|
102
|
+
expect(blocks[0]).toEqual({
|
|
103
|
+
type: "field",
|
|
104
|
+
key: "server.address",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("rejects a field with an unknown key", () => {
|
|
109
|
+
expect(() => parse("@[field](server.nope)")).toThrow(/unknown variable "server.nope"/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("parses a command and collects it", () => {
|
|
113
|
+
const result = parse("@[command](whitelist on)");
|
|
114
|
+
|
|
115
|
+
expect(result.blocks[0]).toEqual({
|
|
116
|
+
type: "command",
|
|
117
|
+
command: "whitelist on",
|
|
118
|
+
});
|
|
119
|
+
expect(result.commands).toEqual([
|
|
120
|
+
"whitelist on",
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("rejects an empty command", () => {
|
|
125
|
+
expect(() => parse("@[command]()")).toThrow(/a command must be a single line/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("parses every open target", () => {
|
|
129
|
+
const { blocks } = parse(
|
|
130
|
+
[
|
|
131
|
+
"@[open](console)",
|
|
132
|
+
"@[open](backups)",
|
|
133
|
+
"@[open](files:server.properties)",
|
|
134
|
+
"@[open](files)",
|
|
135
|
+
"@[open](panel:mods)",
|
|
136
|
+
].join("\n"),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(blocks).toEqual([
|
|
140
|
+
{
|
|
141
|
+
type: "open",
|
|
142
|
+
target: {
|
|
143
|
+
tab: GuideOpenTab.Console,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
type: "open",
|
|
148
|
+
target: {
|
|
149
|
+
tab: GuideOpenTab.Backups,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
type: "open",
|
|
154
|
+
target: {
|
|
155
|
+
tab: GuideOpenTab.Files,
|
|
156
|
+
path: "server.properties",
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
type: "open",
|
|
161
|
+
target: {
|
|
162
|
+
tab: GuideOpenTab.Files,
|
|
163
|
+
path: null,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
type: "open",
|
|
168
|
+
target: {
|
|
169
|
+
tab: GuideOpenTab.Panel,
|
|
170
|
+
tabId: "mods",
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("rejects a panel target without a tab id", () => {
|
|
177
|
+
expect(() => parse("@[open](panel)")).toThrow(/panel target needs a tab id/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("rejects an unknown open target", () => {
|
|
181
|
+
expect(() => parse("@[open](nowhere)")).toThrow(/unknown open target "nowhere"/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("rejects a traversing file path", () => {
|
|
185
|
+
expect(() => parse("@[open](files:../../etc/passwd)")).toThrow();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("rejects an unknown directive", () => {
|
|
189
|
+
expect(() => parse("@[embed](something)")).toThrow(/unknown directive @\[embed\]/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("keeps the youtube directive working", () => {
|
|
193
|
+
const { blocks } = parse("@[youtube](dQw4w9WgXcQ)");
|
|
194
|
+
|
|
195
|
+
expect(blocks[0]).toEqual({
|
|
196
|
+
type: "youtube",
|
|
197
|
+
videoId: "dQw4w9WgXcQ",
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("does not swallow a directive into the paragraph above it", () => {
|
|
202
|
+
const { blocks } = parse(
|
|
203
|
+
[
|
|
204
|
+
"Copy your address:",
|
|
205
|
+
"@[field](server.address)",
|
|
206
|
+
].join("\n"),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(blocks.map((block) => block.type)).toEqual([
|
|
210
|
+
"paragraph",
|
|
211
|
+
"field",
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("conditionals", () => {
|
|
217
|
+
test("parses both branches", () => {
|
|
218
|
+
const { blocks } = parse(
|
|
219
|
+
[
|
|
220
|
+
":::when server.address",
|
|
221
|
+
"Paste this into the game:",
|
|
222
|
+
"",
|
|
223
|
+
"@[field](server.address)",
|
|
224
|
+
":::else",
|
|
225
|
+
"Your server is still setting up.",
|
|
226
|
+
":::",
|
|
227
|
+
].join("\n"),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
expect(blocks).toEqual([
|
|
231
|
+
{
|
|
232
|
+
type: "conditional",
|
|
233
|
+
key: "server.address",
|
|
234
|
+
when: [
|
|
235
|
+
{
|
|
236
|
+
type: "paragraph",
|
|
237
|
+
children: [
|
|
238
|
+
{
|
|
239
|
+
type: "text",
|
|
240
|
+
text: "Paste this into the game:",
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
type: "field",
|
|
246
|
+
key: "server.address",
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
otherwise: [
|
|
250
|
+
{
|
|
251
|
+
type: "paragraph",
|
|
252
|
+
children: [
|
|
253
|
+
{
|
|
254
|
+
type: "text",
|
|
255
|
+
text: "Your server is still setting up.",
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("allows a missing else branch", () => {
|
|
265
|
+
const { blocks } = parse(
|
|
266
|
+
[
|
|
267
|
+
":::when server.address",
|
|
268
|
+
"@[field](server.address)",
|
|
269
|
+
":::",
|
|
270
|
+
].join("\n"),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(blocks[0]).toMatchObject({
|
|
274
|
+
type: "conditional",
|
|
275
|
+
otherwise: [],
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("keeps blocks around a conditional", () => {
|
|
280
|
+
const { blocks } = parse(
|
|
281
|
+
[
|
|
282
|
+
"## Join the game",
|
|
283
|
+
"",
|
|
284
|
+
":::when server.address",
|
|
285
|
+
"@[field](server.address)",
|
|
286
|
+
":::",
|
|
287
|
+
"",
|
|
288
|
+
"Have fun.",
|
|
289
|
+
].join("\n"),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
expect(blocks.map((block) => block.type)).toEqual([
|
|
293
|
+
"heading",
|
|
294
|
+
"conditional",
|
|
295
|
+
"paragraph",
|
|
296
|
+
]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("parses two conditionals in a row", () => {
|
|
300
|
+
const { blocks } = parse(
|
|
301
|
+
[
|
|
302
|
+
":::when server.address",
|
|
303
|
+
"one",
|
|
304
|
+
":::",
|
|
305
|
+
":::when server.playersMax",
|
|
306
|
+
"two",
|
|
307
|
+
":::",
|
|
308
|
+
].join("\n"),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
expect(blocks.map((block) => block.type)).toEqual([
|
|
312
|
+
"conditional",
|
|
313
|
+
"conditional",
|
|
314
|
+
]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("rejects nesting", () => {
|
|
318
|
+
expect(() =>
|
|
319
|
+
parse(
|
|
320
|
+
[
|
|
321
|
+
":::when server.address",
|
|
322
|
+
":::when server.host",
|
|
323
|
+
"nope",
|
|
324
|
+
":::",
|
|
325
|
+
":::",
|
|
326
|
+
].join("\n"),
|
|
327
|
+
),
|
|
328
|
+
).toThrow(/conditionals cannot be nested/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("rejects an unterminated block", () => {
|
|
332
|
+
expect(() =>
|
|
333
|
+
parse(
|
|
334
|
+
[
|
|
335
|
+
":::when server.address",
|
|
336
|
+
"@[field](server.address)",
|
|
337
|
+
].join("\n"),
|
|
338
|
+
),
|
|
339
|
+
).toThrow(/unterminated :::when block/);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("rejects an empty when branch", () => {
|
|
343
|
+
expect(() =>
|
|
344
|
+
parse(
|
|
345
|
+
[
|
|
346
|
+
":::when server.address",
|
|
347
|
+
":::else",
|
|
348
|
+
"nothing",
|
|
349
|
+
":::",
|
|
350
|
+
].join("\n"),
|
|
351
|
+
),
|
|
352
|
+
).toThrow(/empty :::when branch/);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("rejects a stray close", () => {
|
|
356
|
+
expect(() => parse(":::")).toThrow(/unexpected ::: without a matching :::when/);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("rejects an unknown key", () => {
|
|
360
|
+
expect(() =>
|
|
361
|
+
parse(
|
|
362
|
+
[
|
|
363
|
+
":::when server.nope",
|
|
364
|
+
"hi",
|
|
365
|
+
":::",
|
|
366
|
+
].join("\n"),
|
|
367
|
+
),
|
|
368
|
+
).toThrow(/unknown variable "server.nope"/);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("rejects a malformed opener", () => {
|
|
372
|
+
expect(() => parse(":::if server.address")).toThrow(/conditionals open with :::when/);
|
|
373
|
+
});
|
|
374
|
+
});
|