@serverkgg/bridge 0.0.10 → 0.0.12
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 +3 -1
- package/src/blog/articleFrontmatter.test.ts +95 -0
- package/src/blog/articleFrontmatter.ts +99 -0
- package/src/blog/compile.test.ts +148 -0
- package/src/blog/compile.ts +172 -0
- package/src/blog/compiled.ts +22 -0
- package/src/blog/index.ts +3 -0
- package/src/cli/validate.test.ts +116 -1
- package/src/cli/validate.ts +57 -4
- package/src/context/bridgeContext.ts +20 -0
- package/src/guides/compile.ts +70 -54
- package/src/manifest/guide.ts +1 -0
- package/src/manifest/manifest.test.ts +33 -0
- package/src/panel/bridgeControl.ts +1 -0
- package/src/panel/bridgeDetail.ts +1 -0
- package/src/panel/bridgePanel.ts +2 -0
- package/src/probes/index.ts +9 -0
- package/src/probes/minecraftPing.test.ts +344 -0
- package/src/probes/minecraftPing.ts +283 -0
- package/src/probes/raknetPing.test.ts +197 -0
- package/src/probes/raknetPing.ts +228 -0
- package/src/protocol/bridgeLimits.ts +8 -0
- package/src/runtime/bridgeDriver.test.ts +240 -0
- package/src/runtime/bridgeDriver.ts +137 -0
- package/src/runtime/bridgeProbe.ts +13 -1
- package/src/runtime/bridgeSetup.test.ts +387 -1
- package/src/runtime/bridgeSetup.ts +247 -7
- package/src/runtime/bridgeSetupHandlers.test.ts +303 -0
- package/src/runtime/bridgeSetupHandlers.ts +71 -0
- package/src/runtime/serve.ts +8 -41
- package/src/wire/panel.test.ts +62 -0
- package/src/wire/panel.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverkgg/bridge",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
|
|
5
5
|
"homepage": "https://serverk.gg",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"./protocol": "./src/protocol/index.ts",
|
|
22
22
|
"./manifest": "./src/manifest/index.ts",
|
|
23
23
|
"./guides": "./src/guides/index.ts",
|
|
24
|
+
"./blog": "./src/blog/index.ts",
|
|
25
|
+
"./probes": "./src/probes/index.ts",
|
|
24
26
|
"./wire": "./src/wire/index.ts",
|
|
25
27
|
"./package.json": "./package.json"
|
|
26
28
|
},
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseArticleFrontmatter, parseArticleTranslationFrontmatter, splitFrontmatter } from "./articleFrontmatter";
|
|
3
|
+
|
|
4
|
+
const article = (front: string, body = "## عنوان\n\nنص.\n") => `---\n${front}\n---\n\n${body}`;
|
|
5
|
+
|
|
6
|
+
const VALID = `title: كيف تسوي سيرفر
|
|
7
|
+
summary: ثلاث طرق تسوي فيها سيرفر.
|
|
8
|
+
publishedAt: 2026-09-05
|
|
9
|
+
tags:
|
|
10
|
+
- minecraft`;
|
|
11
|
+
|
|
12
|
+
describe("splitFrontmatter", () => {
|
|
13
|
+
test("blanks the frontmatter lines so body line numbers stay true", () => {
|
|
14
|
+
const split = splitFrontmatter("blog/x/ar.md", article(VALID));
|
|
15
|
+
|
|
16
|
+
expect(split.body.split("\n").slice(0, 7)).toEqual([
|
|
17
|
+
"",
|
|
18
|
+
"",
|
|
19
|
+
"",
|
|
20
|
+
"",
|
|
21
|
+
"",
|
|
22
|
+
"",
|
|
23
|
+
"",
|
|
24
|
+
]);
|
|
25
|
+
expect(split.body).toContain("## عنوان");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("rejects a file that does not open with a fence", () => {
|
|
29
|
+
expect(() => splitFrontmatter("blog/x/ar.md", "## عنوان\n")).toThrow('must open with a "---" frontmatter block');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("rejects a frontmatter block that is never closed", () => {
|
|
33
|
+
expect(() => splitFrontmatter("blog/x/ar.md", `---\n${VALID}\n`)).toThrow("never closed");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("parseArticleFrontmatter", () => {
|
|
38
|
+
test("reads every declared field", () => {
|
|
39
|
+
const parsed = parseArticleFrontmatter(
|
|
40
|
+
"blog/x/ar.md",
|
|
41
|
+
article(`${VALID}\nupdatedAt: 2026-09-06\nvideo: dQw4w9WgXcQ`),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(parsed.front.title).toBe("كيف تسوي سيرفر");
|
|
45
|
+
expect(parsed.front.publishedAt).toBe("2026-09-05");
|
|
46
|
+
expect(parsed.front.updatedAt).toBe("2026-09-06");
|
|
47
|
+
expect(parsed.front.video).toBe("dQw4w9WgXcQ");
|
|
48
|
+
expect(parsed.front.tags).toEqual([
|
|
49
|
+
"minecraft",
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("defaults tags to an empty list", () => {
|
|
54
|
+
const parsed = parseArticleFrontmatter(
|
|
55
|
+
"blog/x/ar.md",
|
|
56
|
+
article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05"),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(parsed.front.tags).toEqual([]);
|
|
60
|
+
expect(parsed.front.updatedAt).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("rejects a missing title", () => {
|
|
64
|
+
expect(() => parseArticleFrontmatter("blog/x/ar.md", article("summary: ملخص.\npublishedAt: 2026-09-05"))).toThrow(
|
|
65
|
+
"frontmatter is invalid",
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("rejects a publishedAt that is not a calendar date", () => {
|
|
70
|
+
expect(() =>
|
|
71
|
+
parseArticleFrontmatter(
|
|
72
|
+
"blog/x/ar.md",
|
|
73
|
+
article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05T00:00:00Z"),
|
|
74
|
+
),
|
|
75
|
+
).toThrow("must be a calendar date");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("rejects an unknown key", () => {
|
|
79
|
+
expect(() => parseArticleFrontmatter("blog/x/ar.md", article(`${VALID}\nauthor: mohammed`))).toThrow(
|
|
80
|
+
"frontmatter is invalid",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("parseArticleTranslationFrontmatter", () => {
|
|
86
|
+
test("lets a translation omit the language-independent fields", () => {
|
|
87
|
+
const parsed = parseArticleTranslationFrontmatter(
|
|
88
|
+
"blog/x/en.md",
|
|
89
|
+
article("title: How to make a server\nsummary: The three ways."),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(parsed.front.publishedAt).toBeUndefined();
|
|
93
|
+
expect(parsed.front.tags).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { zGuideTag, zYoutubeId } from "../manifest/guide";
|
|
3
|
+
|
|
4
|
+
export const ARTICLE_TITLE_MAX = 120;
|
|
5
|
+
|
|
6
|
+
export const ARTICLE_SUMMARY_MAX = 300;
|
|
7
|
+
|
|
8
|
+
export const ARTICLE_TAGS_MAX = 8;
|
|
9
|
+
|
|
10
|
+
export const FRONTMATTER_FENCE = "---";
|
|
11
|
+
|
|
12
|
+
export const zArticleDate = z.iso.date("must be a calendar date, for example 2026-09-05");
|
|
13
|
+
|
|
14
|
+
export const articleFrontmatterSchema = z.strictObject({
|
|
15
|
+
title: z.string().min(1).max(ARTICLE_TITLE_MAX),
|
|
16
|
+
summary: z.string().min(1).max(ARTICLE_SUMMARY_MAX),
|
|
17
|
+
publishedAt: zArticleDate,
|
|
18
|
+
updatedAt: zArticleDate.optional(),
|
|
19
|
+
tags: z.array(zGuideTag).max(ARTICLE_TAGS_MAX).default([]),
|
|
20
|
+
video: zYoutubeId.optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type ArticleFrontmatter = z.infer<typeof articleFrontmatterSchema>;
|
|
24
|
+
|
|
25
|
+
export const articleTranslationFrontmatterSchema = articleFrontmatterSchema.extend({
|
|
26
|
+
publishedAt: zArticleDate.optional(),
|
|
27
|
+
tags: z.array(zGuideTag).max(ARTICLE_TAGS_MAX).optional(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export type ArticleTranslationFrontmatter = z.infer<typeof articleTranslationFrontmatterSchema>;
|
|
31
|
+
|
|
32
|
+
export interface SplitFrontmatter {
|
|
33
|
+
data: unknown;
|
|
34
|
+
body: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const splitFrontmatter = (path: string, text: string): SplitFrontmatter => {
|
|
38
|
+
const lines = text.replace(/^/, "").split(/\r?\n/);
|
|
39
|
+
|
|
40
|
+
if (lines[0]?.trim() !== FRONTMATTER_FENCE) {
|
|
41
|
+
throw new Error(`${path}:1 must open with a "${FRONTMATTER_FENCE}" frontmatter block`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const close = lines.findIndex((line, index) => index > 0 && line.trim() === FRONTMATTER_FENCE);
|
|
45
|
+
|
|
46
|
+
if (close === -1) {
|
|
47
|
+
throw new Error(`${path}:1 frontmatter block is never closed with "${FRONTMATTER_FENCE}"`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let data: unknown;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
data = Bun.YAML.parse(lines.slice(1, close).join("\n"));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new Error(`${path}:2 frontmatter is not valid YAML — ${error instanceof Error ? error.message : error}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
data: data ?? {},
|
|
60
|
+
body: [
|
|
61
|
+
...lines.slice(0, close + 1).map(() => ""),
|
|
62
|
+
...lines.slice(close + 1),
|
|
63
|
+
].join("\n"),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export interface ParsedArticleFile<TFrontmatter> {
|
|
68
|
+
front: TFrontmatter;
|
|
69
|
+
body: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parseWith = <TSchema extends z.ZodType>(
|
|
73
|
+
schema: TSchema,
|
|
74
|
+
path: string,
|
|
75
|
+
text: string,
|
|
76
|
+
): ParsedArticleFile<z.infer<TSchema>> => {
|
|
77
|
+
const split = splitFrontmatter(path, text);
|
|
78
|
+
const parsed = schema.safeParse(split.data);
|
|
79
|
+
|
|
80
|
+
if (!parsed.success) {
|
|
81
|
+
throw new Error(`${path} frontmatter is invalid\n${z.prettifyError(parsed.error)}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
front: parsed.data,
|
|
86
|
+
body: split.body,
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const parseArticleFrontmatter = (path: string, text: string): ParsedArticleFile<ArticleFrontmatter> => {
|
|
91
|
+
return parseWith(articleFrontmatterSchema, path, text);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const parseArticleTranslationFrontmatter = (
|
|
95
|
+
path: string,
|
|
96
|
+
text: string,
|
|
97
|
+
): ParsedArticleFile<ArticleTranslationFrontmatter> => {
|
|
98
|
+
return parseWith(articleTranslationFrontmatterSchema, path, text);
|
|
99
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { directorySource } from "../cli/directorySource";
|
|
6
|
+
import { compileArticles, resolveArticleImages } from "./compile";
|
|
7
|
+
|
|
8
|
+
const body = (heading: string) => `## ${heading}\n\nنص المقال هنا.\n`;
|
|
9
|
+
|
|
10
|
+
describe("compileArticles", () => {
|
|
11
|
+
let root = "";
|
|
12
|
+
|
|
13
|
+
const write = (id: string, ar: string, en: string) => {
|
|
14
|
+
mkdirSync(join(root, "blog", id), {
|
|
15
|
+
recursive: true,
|
|
16
|
+
});
|
|
17
|
+
writeFileSync(join(root, "blog", id, "ar.md"), ar);
|
|
18
|
+
writeFileSync(join(root, "blog", id, "en.md"), en);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const article = (front: string, heading: string) => `---\n${front}\n---\n\n${body(heading)}`;
|
|
22
|
+
|
|
23
|
+
const compile = () => compileArticles(directorySource(root));
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
root = mkdtempSync(`${tmpdir()}/serverk-blog-`);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
rmSync(root, {
|
|
31
|
+
force: true,
|
|
32
|
+
recursive: true,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("returns nothing when the package has no blog directory", () => {
|
|
37
|
+
expect(compile().drafts).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("compiles both languages into one article", () => {
|
|
41
|
+
write(
|
|
42
|
+
"make-a-server",
|
|
43
|
+
article("title: كيف تسوي سيرفر\nsummary: ثلاث طرق.\npublishedAt: 2026-09-05\ntags:\n - minecraft", "عنوان"),
|
|
44
|
+
article("title: How to make a server\nsummary: Three ways.", "Heading"),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const [draft] = compile().drafts;
|
|
48
|
+
|
|
49
|
+
expect(draft?.id).toBe("make-a-server");
|
|
50
|
+
expect(draft?.title).toEqual({
|
|
51
|
+
ar: "كيف تسوي سيرفر",
|
|
52
|
+
en: "How to make a server",
|
|
53
|
+
});
|
|
54
|
+
expect(draft?.summary.en).toBe("Three ways.");
|
|
55
|
+
expect(draft?.publishedAt).toBe("2026-09-05");
|
|
56
|
+
expect(draft?.updatedAt).toBeNull();
|
|
57
|
+
expect(draft?.videoId).toBeNull();
|
|
58
|
+
expect(draft?.tags).toEqual([
|
|
59
|
+
"minecraft",
|
|
60
|
+
]);
|
|
61
|
+
expect(draft?.body.ar.at(0)).toMatchObject({
|
|
62
|
+
type: "heading",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("records both markdown files so the bundle digest covers them", () => {
|
|
67
|
+
write(
|
|
68
|
+
"one",
|
|
69
|
+
article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05", "عنوان"),
|
|
70
|
+
article("title: Title\nsummary: Summary.", "Heading"),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(compile().files).toEqual([
|
|
74
|
+
"blog/one/ar.md",
|
|
75
|
+
"blog/one/en.md",
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("orders newest first and breaks ties by id", () => {
|
|
80
|
+
write(
|
|
81
|
+
"beta",
|
|
82
|
+
article("title: ب\nsummary: ملخص.\npublishedAt: 2026-09-05", "عنوان"),
|
|
83
|
+
article("title: Beta\nsummary: Summary.", "Heading"),
|
|
84
|
+
);
|
|
85
|
+
write(
|
|
86
|
+
"alpha",
|
|
87
|
+
article("title: أ\nsummary: ملخص.\npublishedAt: 2026-09-05", "عنوان"),
|
|
88
|
+
article("title: Alpha\nsummary: Summary.", "Heading"),
|
|
89
|
+
);
|
|
90
|
+
write(
|
|
91
|
+
"newer",
|
|
92
|
+
article("title: ج\nsummary: ملخص.\npublishedAt: 2026-09-07", "عنوان"),
|
|
93
|
+
article("title: Newer\nsummary: Summary.", "Heading"),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(compile().drafts.map((draft) => draft.id)).toEqual([
|
|
97
|
+
"newer",
|
|
98
|
+
"alpha",
|
|
99
|
+
"beta",
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("rejects a translation that contradicts a language-independent field", () => {
|
|
104
|
+
write(
|
|
105
|
+
"one",
|
|
106
|
+
article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05", "عنوان"),
|
|
107
|
+
article("title: Title\nsummary: Summary.\npublishedAt: 2026-09-06", "Heading"),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(compile).toThrow("a language-independent field must match");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("accepts a translation that repeats a language-independent field with the same value", () => {
|
|
114
|
+
write(
|
|
115
|
+
"one",
|
|
116
|
+
article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05\ntags:\n - minecraft", "عنوان"),
|
|
117
|
+
article("title: Title\nsummary: Summary.\npublishedAt: 2026-09-05\ntags:\n - minecraft", "Heading"),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(compile().drafts).toHaveLength(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("reports a missing english translation", () => {
|
|
124
|
+
mkdirSync(join(root, "blog", "one"), {
|
|
125
|
+
recursive: true,
|
|
126
|
+
});
|
|
127
|
+
writeFileSync(
|
|
128
|
+
join(root, "blog", "one", "ar.md"),
|
|
129
|
+
article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05", "عنوان"),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(compile).toThrow("missing article body blog/one/en.md");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("resolves into a compiled article the contract accepts", () => {
|
|
136
|
+
write(
|
|
137
|
+
"one",
|
|
138
|
+
article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05\nupdatedAt: 2026-09-06", "عنوان"),
|
|
139
|
+
article("title: Title\nsummary: Summary.", "Heading"),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const [compiled] = resolveArticleImages(compile().drafts, new Map());
|
|
143
|
+
|
|
144
|
+
expect(compiled?.id).toBe("one");
|
|
145
|
+
expect(compiled?.updatedAt).toBe("2026-09-06");
|
|
146
|
+
expect(compiled?.body.en).toHaveLength(2);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type GuideImageAsset,
|
|
3
|
+
markdownBodyReader,
|
|
4
|
+
type ParsedBlock,
|
|
5
|
+
type ResolvedGuideImage,
|
|
6
|
+
resolveGuideBlocks,
|
|
7
|
+
} from "../guides";
|
|
8
|
+
import type { LocalizedText } from "../manifest/localized";
|
|
9
|
+
import { zSlug } from "../manifest/primitives";
|
|
10
|
+
import type { PackageSource } from "../manifest/source";
|
|
11
|
+
import {
|
|
12
|
+
type ArticleFrontmatter,
|
|
13
|
+
type ArticleTranslationFrontmatter,
|
|
14
|
+
parseArticleFrontmatter,
|
|
15
|
+
parseArticleTranslationFrontmatter,
|
|
16
|
+
} from "./articleFrontmatter";
|
|
17
|
+
import { type CompiledArticle, compiledArticleSchema } from "./compiled";
|
|
18
|
+
|
|
19
|
+
export const BLOG_DIRECTORY = "blog";
|
|
20
|
+
|
|
21
|
+
export const ARTICLE_SOURCE_MAX = 200;
|
|
22
|
+
|
|
23
|
+
export interface DraftArticle {
|
|
24
|
+
id: string;
|
|
25
|
+
title: LocalizedText;
|
|
26
|
+
summary: LocalizedText;
|
|
27
|
+
publishedAt: string;
|
|
28
|
+
updatedAt: string | null;
|
|
29
|
+
tags: string[];
|
|
30
|
+
videoId: string | null;
|
|
31
|
+
body: {
|
|
32
|
+
ar: ParsedBlock[];
|
|
33
|
+
en: ParsedBlock[];
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ArticlePack {
|
|
38
|
+
drafts: DraftArticle[];
|
|
39
|
+
files: string[];
|
|
40
|
+
images: GuideImageAsset[];
|
|
41
|
+
commands: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ARTICLE_PATH_PATTERN = /^blog\/([^/]+)\/ar\.md$/;
|
|
45
|
+
|
|
46
|
+
const articleIds = (source: PackageSource): string[] => {
|
|
47
|
+
const ids: string[] = [];
|
|
48
|
+
|
|
49
|
+
for (const path of source.list()) {
|
|
50
|
+
const match = ARTICLE_PATH_PATTERN.exec(path);
|
|
51
|
+
|
|
52
|
+
if (!match) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const id = zSlug.safeParse(match[1]);
|
|
57
|
+
|
|
58
|
+
if (!id.success) {
|
|
59
|
+
throw new Error(`"${path}" is not a valid article folder — the folder name must be a slug`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
ids.push(id.data);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return ids.sort();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const readText = (source: PackageSource, path: string): string => {
|
|
69
|
+
const bytes = source.read(path);
|
|
70
|
+
|
|
71
|
+
if (!bytes) {
|
|
72
|
+
throw new Error(`missing article body ${path}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return new TextDecoder().decode(bytes);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const SHARED_KEYS = [
|
|
79
|
+
"publishedAt",
|
|
80
|
+
"updatedAt",
|
|
81
|
+
"video",
|
|
82
|
+
] as const;
|
|
83
|
+
|
|
84
|
+
const assertSharedFields = (path: string, article: ArticleFrontmatter, translation: ArticleTranslationFrontmatter) => {
|
|
85
|
+
for (const key of SHARED_KEYS) {
|
|
86
|
+
if (translation[key] !== undefined && translation[key] !== article[key]) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`${path} frontmatter "${key}" is "${translation[key]}" but ar.md declares "${article[key] ?? "nothing"}" — a language-independent field must match`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (translation.tags !== undefined && translation.tags.join(",") !== article.tags.join(",")) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`${path} frontmatter "tags" is [${translation.tags.join(", ")}] but ar.md declares [${article.tags.join(", ")}] — a language-independent field must match`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const compileArticles = (source: PackageSource): ArticlePack => {
|
|
101
|
+
const ids = articleIds(source);
|
|
102
|
+
|
|
103
|
+
if (ids.length > ARTICLE_SOURCE_MAX) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`${ids.length} articles found under ${BLOG_DIRECTORY}/ — at most ${ARTICLE_SOURCE_MAX} are allowed`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const reader = markdownBodyReader(source);
|
|
110
|
+
const drafts: DraftArticle[] = [];
|
|
111
|
+
|
|
112
|
+
for (const id of ids) {
|
|
113
|
+
const arPath = `${BLOG_DIRECTORY}/${id}/ar.md`;
|
|
114
|
+
const enPath = `${BLOG_DIRECTORY}/${id}/en.md`;
|
|
115
|
+
|
|
116
|
+
const ar = parseArticleFrontmatter(arPath, readText(source, arPath));
|
|
117
|
+
const en = parseArticleTranslationFrontmatter(enPath, readText(source, enPath));
|
|
118
|
+
|
|
119
|
+
assertSharedFields(enPath, ar.front, en.front);
|
|
120
|
+
|
|
121
|
+
drafts.push({
|
|
122
|
+
id,
|
|
123
|
+
title: {
|
|
124
|
+
ar: ar.front.title,
|
|
125
|
+
en: en.front.title,
|
|
126
|
+
},
|
|
127
|
+
summary: {
|
|
128
|
+
ar: ar.front.summary,
|
|
129
|
+
en: en.front.summary,
|
|
130
|
+
},
|
|
131
|
+
publishedAt: ar.front.publishedAt,
|
|
132
|
+
updatedAt: ar.front.updatedAt ?? null,
|
|
133
|
+
tags: ar.front.tags,
|
|
134
|
+
videoId: ar.front.video ?? null,
|
|
135
|
+
body: {
|
|
136
|
+
ar: reader.read(arPath, ar.body),
|
|
137
|
+
en: reader.read(enPath, en.body),
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
drafts.sort((left, right) => {
|
|
143
|
+
return right.publishedAt.localeCompare(left.publishedAt) || left.id.localeCompare(right.id);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
drafts,
|
|
148
|
+
files: [
|
|
149
|
+
...reader.files,
|
|
150
|
+
...reader.images.keys(),
|
|
151
|
+
],
|
|
152
|
+
images: [
|
|
153
|
+
...reader.images.values(),
|
|
154
|
+
],
|
|
155
|
+
commands: reader.commands,
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const resolveArticleImages = (
|
|
160
|
+
drafts: DraftArticle[],
|
|
161
|
+
resolved: Map<string, ResolvedGuideImage>,
|
|
162
|
+
): CompiledArticle[] => {
|
|
163
|
+
return drafts.map((draft) => {
|
|
164
|
+
return compiledArticleSchema.parse({
|
|
165
|
+
...draft,
|
|
166
|
+
body: {
|
|
167
|
+
ar: resolveGuideBlocks(draft.body.ar, resolved),
|
|
168
|
+
en: resolveGuideBlocks(draft.body.en, resolved),
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { GUIDE_BODY_MAX_BLOCKS, guideBlockSchema } from "../guides";
|
|
3
|
+
import { zGuideTag, zYoutubeId } from "../manifest/guide";
|
|
4
|
+
import { localizedTextSchema } from "../manifest/localized";
|
|
5
|
+
import { zSlug } from "../manifest/primitives";
|
|
6
|
+
import { ARTICLE_TAGS_MAX } from "./articleFrontmatter";
|
|
7
|
+
|
|
8
|
+
export const compiledArticleSchema = z.object({
|
|
9
|
+
id: zSlug,
|
|
10
|
+
title: localizedTextSchema,
|
|
11
|
+
summary: localizedTextSchema,
|
|
12
|
+
publishedAt: z.iso.date(),
|
|
13
|
+
updatedAt: z.iso.date().nullable(),
|
|
14
|
+
tags: z.array(zGuideTag).max(ARTICLE_TAGS_MAX).default([]),
|
|
15
|
+
videoId: zYoutubeId.nullable(),
|
|
16
|
+
body: z.object({
|
|
17
|
+
ar: z.array(guideBlockSchema).min(1).max(GUIDE_BODY_MAX_BLOCKS),
|
|
18
|
+
en: z.array(guideBlockSchema).min(1).max(GUIDE_BODY_MAX_BLOCKS),
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type CompiledArticle = z.infer<typeof compiledArticleSchema>;
|
package/src/cli/validate.test.ts
CHANGED
|
@@ -2,7 +2,15 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { BridgeKind, BridgeSetupStepKind } from "../core";
|
|
6
|
+
import { GuideOpenTab } from "../guides";
|
|
7
|
+
import type { BridgeSetupModule } from "../modules";
|
|
8
|
+
import { runValidate, setupReportLines } from "./validate";
|
|
9
|
+
|
|
10
|
+
const text = (value: string) => ({
|
|
11
|
+
ar: value,
|
|
12
|
+
en: value,
|
|
13
|
+
});
|
|
6
14
|
|
|
7
15
|
describe("validate", () => {
|
|
8
16
|
let root = "";
|
|
@@ -87,3 +95,110 @@ describe("validate", () => {
|
|
|
87
95
|
await expect(validate()).rejects.toThrow("missing serverk.yml");
|
|
88
96
|
});
|
|
89
97
|
});
|
|
98
|
+
|
|
99
|
+
describe("the setup report", () => {
|
|
100
|
+
test("says none for a driver that declares no setup", () => {
|
|
101
|
+
expect(setupReportLines(undefined)).toEqual([
|
|
102
|
+
" setup: none",
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("names every step, its kind, whether it is required, and where it lands", () => {
|
|
107
|
+
const setup: BridgeSetupModule = {
|
|
108
|
+
kind: BridgeKind.Setup,
|
|
109
|
+
steps: [
|
|
110
|
+
{
|
|
111
|
+
kind: BridgeSetupStepKind.Form,
|
|
112
|
+
id: "license",
|
|
113
|
+
title: text("Licence"),
|
|
114
|
+
required: true,
|
|
115
|
+
tab: "license",
|
|
116
|
+
section: "license",
|
|
117
|
+
fields: [
|
|
118
|
+
"LICENSE_KEY",
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
kind: BridgeSetupStepKind.Driver,
|
|
123
|
+
id: "verify",
|
|
124
|
+
title: text("Verify"),
|
|
125
|
+
required: true,
|
|
126
|
+
requiresRunning: true,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
kind: BridgeSetupStepKind.Open,
|
|
130
|
+
id: "txadmin",
|
|
131
|
+
title: text("txAdmin"),
|
|
132
|
+
required: false,
|
|
133
|
+
target: {
|
|
134
|
+
tab: GuideOpenTab.Panel,
|
|
135
|
+
tabId: "license",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
kind: BridgeSetupStepKind.Open,
|
|
140
|
+
id: "invite",
|
|
141
|
+
title: text("Invite"),
|
|
142
|
+
required: false,
|
|
143
|
+
target: {
|
|
144
|
+
tab: GuideOpenTab.Access,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
submit: async () => {
|
|
149
|
+
return;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
expect(setupReportLines(setup)).toEqual([
|
|
154
|
+
" setup: 4 step(s)",
|
|
155
|
+
" license — form, required → license/license [LICENSE_KEY]",
|
|
156
|
+
" verify — driver, required → driver (needs the game running)",
|
|
157
|
+
' txadmin — open, optional → panel tab "license"',
|
|
158
|
+
" invite — open, optional → access",
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("leaves a driver step that needs nothing running with no target", () => {
|
|
163
|
+
expect(
|
|
164
|
+
setupReportLines({
|
|
165
|
+
kind: BridgeKind.Setup,
|
|
166
|
+
steps: [
|
|
167
|
+
{
|
|
168
|
+
kind: BridgeSetupStepKind.Driver,
|
|
169
|
+
id: "sign-in",
|
|
170
|
+
title: text("Sign in"),
|
|
171
|
+
required: false,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
submit: async () => {
|
|
175
|
+
return;
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
).toEqual([
|
|
179
|
+
" setup: 1 step(s)",
|
|
180
|
+
" sign-in — driver, optional → driver",
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("reads an omitted required as required, the way the platform does", () => {
|
|
185
|
+
expect(
|
|
186
|
+
setupReportLines({
|
|
187
|
+
kind: BridgeKind.Setup,
|
|
188
|
+
steps: [
|
|
189
|
+
{
|
|
190
|
+
kind: BridgeSetupStepKind.Driver,
|
|
191
|
+
id: "sign-in",
|
|
192
|
+
title: text("Sign in"),
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
submit: async () => {
|
|
196
|
+
return;
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
).toEqual([
|
|
200
|
+
" setup: 1 step(s)",
|
|
201
|
+
" sign-in — driver, required → driver",
|
|
202
|
+
]);
|
|
203
|
+
});
|
|
204
|
+
});
|