@serverkgg/bridge 0.1.8 → 0.1.10
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 +1 -1
- package/rules/package.md +1 -0
- package/src/bot/botCauses.test.ts +88 -0
- package/src/bot/botCauses.ts +12 -0
- package/src/bot/botDeploy.test.ts +32 -0
- package/src/bot/botDeploy.ts +6 -0
- package/src/bot/botEnvironment.test.ts +218 -1
- package/src/bot/botEnvironment.ts +85 -3
- package/src/bot/botVariables.test.ts +32 -0
- package/src/bot/botVariables.ts +9 -0
- package/src/bot/detect/corpus/mruniquehacker-knightbot-md.json +35 -0
- package/src/bot/detect/detect.test.ts +22 -0
- package/src/bot/detect/keys.test.ts +50 -0
- package/src/bot/detect/keys.ts +45 -1
- package/src/cli/validate.test.ts +33 -3
- package/src/cli/validate.ts +13 -5
- package/src/guides/markdown.test.ts +118 -0
- package/src/guides/markdown.ts +24 -2
- package/src/manifest/manifest.test.ts +229 -0
- package/src/manifest/manifest.ts +109 -1
- package/src/protocol/bridgeCrash.ts +10 -0
- package/src/protocol/bridgeEvents.ts +2 -0
- package/src/runtime/bridgeDriver.test.ts +24 -0
- package/src/runtime/bridgeDriver.ts +6 -2
- package/src/runtime/bridgeEvents.test.ts +13 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": {
|
|
3
|
+
"url": "https://github.com/mruniquehacker/Knightbot-MD",
|
|
4
|
+
"branch": "main",
|
|
5
|
+
"commit": "871bce832169c3a52a632dc100c1f235cb35977c",
|
|
6
|
+
"stars": 8128
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
".gitignore",
|
|
10
|
+
"README.md",
|
|
11
|
+
"config.js",
|
|
12
|
+
"index.js",
|
|
13
|
+
"main.js",
|
|
14
|
+
"package.json",
|
|
15
|
+
"settings.js"
|
|
16
|
+
],
|
|
17
|
+
"contents": {
|
|
18
|
+
"package.json": "{\n \"name\": \"knightbot\",\n \"version\": \"1.0.0\",\n \"description\": \"WhatsApp Bot\",\n \"main\": \"index.js\",\n \"scripts\": {\n \"start\": \"node index.js\",\n \"start:optimized\": \"node --max-old-space-size=512 --optimize-for-size --gc-interval=100 index.js\",\n \"cleanup\": \"node cleanup.js\",\n \"reset-session\": \"node reset-session.js\",\n \"start:clean\": \"npm run cleanup && npm run start:optimized\",\n \"start:fresh\": \"npm run reset-session && npm start\",\n \"install:panel\": \"npm install --legacy-peer-deps\",\n \"install:force\": \"npm install --force\",\n \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\",\n \"docker:build\": \"docker build -t docker run -e SESSION_ID=$SESSION_ID knightbot\"\n },\n \"keywords\": [\n \"whatsapp-bot\"\n ],\n \"author\": \"\",\n \"license\": \"ISC\",\n \"dependencies\": {\n \"@adiwajshing/keyed-db\": \"^0.2.4\",\n \"@ffmpeg/ffmpeg\": \"^0.12.15\",\n \"@hapi/boom\": \"^10.0.1\",\n \"@types/node\": \"^18.0.6\",\n \"@whiskeysockets/baileys\": \"^v7.0.0-rc.9\",\n \"awesome-phonenumber\": \"^5.9.0\",\n \"axios\": \"^1.8.4\",\n \"chalk\": \"^4.1.2\",\n \"cheerio\": \"^1.0.0-rc.12\",\n \"cookie\": \"^0.5.0\",\n \"dotenv\": \"^16.4.5\",\n \"events\": \"^3.3.0\",\n \"file-type\": \"^16.5.4\",\n \"fluent-ffmpeg\": \"^2.1.3\",\n \"form-data\": \"^4.0.1\",\n \"fs-extra\": \"^11.2.0\",\n \"gtts\": \"^0.2.1\",\n \"human-readable\": \"^0.2.1\",\n \"jimp\": \"^1.6.0\",\n \"jsdom\": \"^22.1.0\",\n \"libphonenumber-js\": \"^1.11.18\",\n \"libsignal\": \"^2.0.1\",\n \"link-preview-js\": \"^3.0.5\",\n \"moment-timezone\": \"^0.5.43\",\n \"mumaker\": \"^2.0.0\",\n \"node-cache\": \"^5.1.2\",\n \"node-fetch\": \"^2.7.0\",\n \"node-id3\": \"^0.2.3\",\n \"node-webpmux\": \"^3.1.0\",\n \"node-youtube-music\": \"^0.8.3\",\n \"performance-now\": \"^2.1.0\",\n \"phin\": \"^3.7.1\",\n \"pino\": \"^8.21.0\",\n \"qrcode\": \"^1.5.4\",\n \"qrcode-reader\": \"^1.0.4\",\n \"qrcode-terminal\": \"^0.12.0\",\n \"request\": \"^2.88.2\",\n \"ruhend-scraper\": \"^8.3.0\",\n \"safe-stable-stringify\": \"^2.5.0\",\n \"set-cookie\": \"^0.0.4\",\n \"sharp\": \"^0.32.6\",\n \"tough-cookie\": \"^5.0.0\",\n \"translate-google-api\": \"^1.0.4\",\n \"ws\": \"^8.17.1\",\n \"yargs\": \"^17.6.0\",\n \"yargs-parser\": \"^21.1.1\",\n \"youtube-yts\": \"^2.0.0\",\n \"youtubedl-core\": \"^4.11.7\",\n \"yt-search\": \"^2.12.1\",\n \"ytdl-core\": \"^4.11.5\"\n },\n \"overrides\": {\n \"jimp\": \"^1.6.0\"\n },\n \"engines\": {\n \"node\": \">=18.0.0\"\n }\n}\n"
|
|
19
|
+
},
|
|
20
|
+
"expected": {
|
|
21
|
+
"runtime": "node",
|
|
22
|
+
"packageManager": "npm",
|
|
23
|
+
"framework": null,
|
|
24
|
+
"platform": null,
|
|
25
|
+
"install": "npm install",
|
|
26
|
+
"build": null,
|
|
27
|
+
"start": "node index.js",
|
|
28
|
+
"nodeVersion": "18",
|
|
29
|
+
"pythonVersion": null,
|
|
30
|
+
"tokenKey": null,
|
|
31
|
+
"warnings": [
|
|
32
|
+
"no-lockfile"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -678,6 +678,28 @@ describe("detect warnings", () => {
|
|
|
678
678
|
|
|
679
679
|
expect(quiet.warnings).not.toContain(BotInspectWarning.ReservedEnvKey);
|
|
680
680
|
});
|
|
681
|
+
|
|
682
|
+
it("keeps quiet about the web address and raw port variables an example file lists, and warns about the switches", () => {
|
|
683
|
+
const quiet = project({
|
|
684
|
+
...DISCORD_NODE,
|
|
685
|
+
".env.example": "DISCORD_TOKEN=\nSERVERK_WEB_URL=\nSERVERK_WEB_HOST=\nSERVERK_PORT_1=\nSERVERK_PORT_2=\n",
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
expect(quiet.env.map((entry) => entry.key)).toEqual([
|
|
689
|
+
"DISCORD_TOKEN",
|
|
690
|
+
]);
|
|
691
|
+
expect(quiet.warnings).not.toContain(BotInspectWarning.ReservedEnvKey);
|
|
692
|
+
|
|
693
|
+
const loud = project({
|
|
694
|
+
...DISCORD_NODE,
|
|
695
|
+
".env.example": "DISCORD_TOKEN=\nBOT_WEB=on\n",
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
expect(loud.env.map((entry) => entry.key)).toEqual([
|
|
699
|
+
"DISCORD_TOKEN",
|
|
700
|
+
]);
|
|
701
|
+
expect(loud.warnings).toContain(BotInspectWarning.ReservedEnvKey);
|
|
702
|
+
});
|
|
681
703
|
});
|
|
682
704
|
|
|
683
705
|
describe("detect with serverk.json", () => {
|
|
@@ -3,9 +3,21 @@ import {
|
|
|
3
3
|
BOT_AMBIENT_KEYS,
|
|
4
4
|
BOT_PLATFORM_KEYS,
|
|
5
5
|
BOT_PLATFORM_SECRET_KEYS,
|
|
6
|
+
BOT_PORT_1,
|
|
7
|
+
BOT_PORT_1_KEY,
|
|
8
|
+
BOT_PORT_2,
|
|
9
|
+
BOT_PORT_2_KEY,
|
|
6
10
|
BOT_PROTECTED_KEYS,
|
|
7
11
|
BOT_PROVIDED_KEYS,
|
|
12
|
+
BOT_RAW_PORT_KEYS,
|
|
13
|
+
BOT_WEB,
|
|
14
|
+
BOT_WEB_PORT_KEY,
|
|
8
15
|
isReservedBotKey,
|
|
16
|
+
PORT,
|
|
17
|
+
SERVERK_PORT_1,
|
|
18
|
+
SERVERK_PORT_2,
|
|
19
|
+
SERVERK_WEB_HOST,
|
|
20
|
+
SERVERK_WEB_URL,
|
|
9
21
|
} from "./keys";
|
|
10
22
|
|
|
11
23
|
describe("isReservedBotKey", () => {
|
|
@@ -21,6 +33,21 @@ describe("isReservedBotKey", () => {
|
|
|
21
33
|
}
|
|
22
34
|
});
|
|
23
35
|
|
|
36
|
+
it("reserves the web and port switches the platform sets", () => {
|
|
37
|
+
for (const key of [
|
|
38
|
+
BOT_WEB,
|
|
39
|
+
BOT_PORT_1,
|
|
40
|
+
BOT_PORT_2,
|
|
41
|
+
]) {
|
|
42
|
+
expect(BOT_PLATFORM_KEYS).toContain(key);
|
|
43
|
+
expect(isReservedBotKey(key)).toBe(true);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("leaves PORT to the customer, so a bot without the web port keeps its own", () => {
|
|
48
|
+
expect(isReservedBotKey(PORT)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
24
51
|
it("leaves the customer's own keys alone, case included", () => {
|
|
25
52
|
for (const key of [
|
|
26
53
|
"DISCORD_TOKEN",
|
|
@@ -47,6 +74,19 @@ describe("BOT_AMBIENT_KEYS", () => {
|
|
|
47
74
|
}
|
|
48
75
|
});
|
|
49
76
|
|
|
77
|
+
it("holds the web address and raw port variables, so detection never asks for them", () => {
|
|
78
|
+
for (const key of [
|
|
79
|
+
SERVERK_WEB_URL,
|
|
80
|
+
SERVERK_WEB_HOST,
|
|
81
|
+
SERVERK_PORT_1,
|
|
82
|
+
SERVERK_PORT_2,
|
|
83
|
+
]) {
|
|
84
|
+
expect(BOT_PROVIDED_KEYS).toContain(key);
|
|
85
|
+
expect(BOT_AMBIENT_KEYS).toContain(key);
|
|
86
|
+
expect(isReservedBotKey(key)).toBe(true);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
50
90
|
it("leaves a bot's own keys out", () => {
|
|
51
91
|
for (const key of [
|
|
52
92
|
"DISCORD_TOKEN",
|
|
@@ -57,3 +97,13 @@ describe("BOT_AMBIENT_KEYS", () => {
|
|
|
57
97
|
}
|
|
58
98
|
});
|
|
59
99
|
});
|
|
100
|
+
|
|
101
|
+
describe("BOT_RAW_PORT_KEYS", () => {
|
|
102
|
+
it("names the two raw ports apart from the web port", () => {
|
|
103
|
+
expect(BOT_RAW_PORT_KEYS).toEqual([
|
|
104
|
+
BOT_PORT_1_KEY,
|
|
105
|
+
BOT_PORT_2_KEY,
|
|
106
|
+
]);
|
|
107
|
+
expect(BOT_RAW_PORT_KEYS).not.toContain(BOT_WEB_PORT_KEY);
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/bot/detect/keys.ts
CHANGED
|
@@ -18,12 +18,49 @@ export const SNAPSHOT_ID = "SNAPSHOT_ID";
|
|
|
18
18
|
|
|
19
19
|
export const BOT_PLATFORM = "BOT_PLATFORM";
|
|
20
20
|
|
|
21
|
+
export const BOT_WEB = "BOT_WEB";
|
|
22
|
+
|
|
23
|
+
export const BOT_PORT_1 = "BOT_PORT_1";
|
|
24
|
+
|
|
25
|
+
export const BOT_PORT_2 = "BOT_PORT_2";
|
|
26
|
+
|
|
27
|
+
export const BOT_SWITCH_ON = "on";
|
|
28
|
+
|
|
21
29
|
export const GIT_TOKEN = "GIT_TOKEN";
|
|
22
30
|
|
|
23
31
|
export const SERVERK_BOT = "SERVERK_BOT";
|
|
24
32
|
|
|
25
33
|
export const SERVERK_SERVER_CODE = "SERVERK_SERVER_CODE";
|
|
26
34
|
|
|
35
|
+
export const SERVERK_WEB_URL = "SERVERK_WEB_URL";
|
|
36
|
+
|
|
37
|
+
export const SERVERK_WEB_HOST = "SERVERK_WEB_HOST";
|
|
38
|
+
|
|
39
|
+
export const SERVERK_PORT_1 = "SERVERK_PORT_1";
|
|
40
|
+
|
|
41
|
+
export const SERVERK_PORT_2 = "SERVERK_PORT_2";
|
|
42
|
+
|
|
43
|
+
export const PORT = "PORT";
|
|
44
|
+
|
|
45
|
+
export const BOT_WEB_PORT_KEY = "web";
|
|
46
|
+
|
|
47
|
+
export const BOT_PORT_1_KEY = "port1";
|
|
48
|
+
|
|
49
|
+
export const BOT_PORT_2_KEY = "port2";
|
|
50
|
+
|
|
51
|
+
export const BOT_RAW_PORT_KEYS = [
|
|
52
|
+
BOT_PORT_1_KEY,
|
|
53
|
+
BOT_PORT_2_KEY,
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export type BotRawPortKey = (typeof BOT_RAW_PORT_KEYS)[number];
|
|
57
|
+
|
|
58
|
+
export const BOT_PORT_SWITCHES = {
|
|
59
|
+
[BOT_WEB_PORT_KEY]: BOT_WEB,
|
|
60
|
+
[BOT_PORT_1_KEY]: BOT_PORT_1,
|
|
61
|
+
[BOT_PORT_2_KEY]: BOT_PORT_2,
|
|
62
|
+
} as const;
|
|
63
|
+
|
|
27
64
|
export const BOT_PLATFORM_KEYS: readonly string[] = [
|
|
28
65
|
REPO_URL,
|
|
29
66
|
REPO_REF,
|
|
@@ -34,6 +71,9 @@ export const BOT_PLATFORM_KEYS: readonly string[] = [
|
|
|
34
71
|
DEPLOYMENT_ID,
|
|
35
72
|
SNAPSHOT_ID,
|
|
36
73
|
BOT_PLATFORM,
|
|
74
|
+
BOT_WEB,
|
|
75
|
+
BOT_PORT_1,
|
|
76
|
+
BOT_PORT_2,
|
|
37
77
|
];
|
|
38
78
|
|
|
39
79
|
export const BOT_PLATFORM_SECRET_KEYS: readonly string[] = [
|
|
@@ -52,6 +92,10 @@ export const BOT_PROVIDED_KEYS: readonly string[] = [
|
|
|
52
92
|
SERVERK_BOT,
|
|
53
93
|
SERVERK_SERVER_CODE,
|
|
54
94
|
BOT_PLATFORM,
|
|
95
|
+
SERVERK_WEB_URL,
|
|
96
|
+
SERVERK_WEB_HOST,
|
|
97
|
+
SERVERK_PORT_1,
|
|
98
|
+
SERVERK_PORT_2,
|
|
55
99
|
];
|
|
56
100
|
|
|
57
101
|
export const BOT_AMBIENT_KEYS: readonly string[] = [
|
|
@@ -59,7 +103,7 @@ export const BOT_AMBIENT_KEYS: readonly string[] = [
|
|
|
59
103
|
...BOT_PROVIDED_KEYS,
|
|
60
104
|
"NODE_ENV",
|
|
61
105
|
"NODE_OPTIONS",
|
|
62
|
-
|
|
106
|
+
PORT,
|
|
63
107
|
"HOST",
|
|
64
108
|
"HOSTNAME",
|
|
65
109
|
"DEBUG",
|
package/src/cli/validate.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { renderDetectTable } from "../bot/detect";
|
|
5
|
+
import { BOT_SWITCH_ON, BOT_WEB, BOT_WEB_PORT_KEY, renderDetectTable } from "../bot/detect";
|
|
6
6
|
import { BridgeKind, BridgeSetupStepKind } from "../core";
|
|
7
7
|
import { GuideOpenTab, type GuidePack, type ParsedBlock, type ParsedLeafBlock } from "../guides";
|
|
8
8
|
import { MANIFEST_SCHEMA_VERSION, PackageKind, type RunnableManifest } from "../manifest";
|
|
@@ -600,7 +600,7 @@ describe("validating a bot package", () => {
|
|
|
600
600
|
);
|
|
601
601
|
});
|
|
602
602
|
|
|
603
|
-
it("warns about a port
|
|
603
|
+
it("warns about a bot port with no activeWhen but still validates it", async () => {
|
|
604
604
|
writeBot(
|
|
605
605
|
botManifest({
|
|
606
606
|
container: {
|
|
@@ -616,10 +616,40 @@ describe("validating a bot package", () => {
|
|
|
616
616
|
|
|
617
617
|
expect(lines).toContain("bot-node is valid");
|
|
618
618
|
expect(lines).toContain(
|
|
619
|
-
" warning: a bot package declares 1 port(s);
|
|
619
|
+
" warning: a bot package declares 1 port(s) with no activeWhen; a bot publishes a port only while its author turns it on",
|
|
620
620
|
);
|
|
621
621
|
});
|
|
622
622
|
|
|
623
|
+
it("validates a bot web port its author turns on without a warning", async () => {
|
|
624
|
+
writeBot(
|
|
625
|
+
botManifest({
|
|
626
|
+
container: {
|
|
627
|
+
image: "serverkgg/bot-node",
|
|
628
|
+
ports: [
|
|
629
|
+
{
|
|
630
|
+
...WEBHOOK_PORT,
|
|
631
|
+
key: BOT_WEB_PORT_KEY,
|
|
632
|
+
web: true,
|
|
633
|
+
activeWhen: [
|
|
634
|
+
{
|
|
635
|
+
variable: BOT_WEB,
|
|
636
|
+
values: [
|
|
637
|
+
BOT_SWITCH_ON,
|
|
638
|
+
],
|
|
639
|
+
},
|
|
640
|
+
],
|
|
641
|
+
},
|
|
642
|
+
],
|
|
643
|
+
},
|
|
644
|
+
}),
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
await validate();
|
|
648
|
+
|
|
649
|
+
expect(lines).toContain("bot-node is valid");
|
|
650
|
+
expect(lines.some((line) => line.includes("warning:"))).toBe(false);
|
|
651
|
+
});
|
|
652
|
+
|
|
623
653
|
it("fails a bot that declares platform secrets", async () => {
|
|
624
654
|
writeBot(
|
|
625
655
|
botManifest({
|
package/src/cli/validate.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { pathToFileURL } from "node:url";
|
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { type ArticlePack, compileArticles } from "../blog";
|
|
7
|
-
import { DETECT_TABLE_FILE, renderDetectTable } from "../bot/detect";
|
|
7
|
+
import { BOT_PORT_SWITCHES, DETECT_TABLE_FILE, renderDetectTable } from "../bot/detect";
|
|
8
8
|
import { BridgeKind, PortProtocol } from "../core";
|
|
9
9
|
import {
|
|
10
10
|
GuideOpenTab,
|
|
@@ -599,14 +599,18 @@ export const setupReportLines = (setup: BridgeSetupModule | undefined): string[]
|
|
|
599
599
|
};
|
|
600
600
|
|
|
601
601
|
export const botPortLines = (manifest: RunnableManifest): string[] => {
|
|
602
|
-
|
|
602
|
+
if (!isBotManifest(manifest)) {
|
|
603
|
+
return [];
|
|
604
|
+
}
|
|
603
605
|
|
|
604
|
-
|
|
606
|
+
const ungated = manifest.container.ports.filter((port) => port.activeWhen.length === 0).length;
|
|
607
|
+
|
|
608
|
+
if (ungated === 0) {
|
|
605
609
|
return [];
|
|
606
610
|
}
|
|
607
611
|
|
|
608
612
|
return [
|
|
609
|
-
` warning: a bot package declares ${
|
|
613
|
+
` warning: a bot package declares ${ungated} port(s) with no activeWhen; a bot publishes a port only while its author turns it on`,
|
|
610
614
|
];
|
|
611
615
|
};
|
|
612
616
|
|
|
@@ -680,7 +684,11 @@ const validateRunnablePackage = async (
|
|
|
680
684
|
}
|
|
681
685
|
|
|
682
686
|
validateDriver(driver);
|
|
683
|
-
validatePortActivation(
|
|
687
|
+
validatePortActivation(
|
|
688
|
+
manifest.container.ports,
|
|
689
|
+
driver,
|
|
690
|
+
isBotManifest(manifest) ? Object.values(BOT_PORT_SWITCHES) : [],
|
|
691
|
+
);
|
|
684
692
|
|
|
685
693
|
const confirmLines = confirmReportLines(driver);
|
|
686
694
|
|
|
@@ -390,3 +390,121 @@ describe("conditionals", () => {
|
|
|
390
390
|
expect(() => parse(":::if server.address")).toThrow(/conditionals open with :::when/);
|
|
391
391
|
});
|
|
392
392
|
});
|
|
393
|
+
|
|
394
|
+
describe("heading anchors", () => {
|
|
395
|
+
it("takes the id after a heading and keeps it out of the heading text", () => {
|
|
396
|
+
const { blocks } = parse(
|
|
397
|
+
[
|
|
398
|
+
"## The token is invalid {#token-invalid}",
|
|
399
|
+
"",
|
|
400
|
+
"### التوكن غير صحيح {#token-invalid-2}",
|
|
401
|
+
].join("\n"),
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
expect(blocks).toEqual([
|
|
405
|
+
{
|
|
406
|
+
type: "heading",
|
|
407
|
+
level: 2,
|
|
408
|
+
id: "token-invalid",
|
|
409
|
+
children: [
|
|
410
|
+
{
|
|
411
|
+
type: "text",
|
|
412
|
+
text: "The token is invalid",
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
type: "heading",
|
|
418
|
+
level: 3,
|
|
419
|
+
id: "token-invalid-2",
|
|
420
|
+
children: [
|
|
421
|
+
{
|
|
422
|
+
type: "text",
|
|
423
|
+
text: "التوكن غير صحيح",
|
|
424
|
+
},
|
|
425
|
+
],
|
|
426
|
+
},
|
|
427
|
+
]);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("keeps inline markup before the anchor", () => {
|
|
431
|
+
const { blocks } = parse("## `ffmpeg` is missing {#ffmpeg-missing}");
|
|
432
|
+
|
|
433
|
+
expect(blocks[0]).toEqual({
|
|
434
|
+
type: "heading",
|
|
435
|
+
level: 2,
|
|
436
|
+
id: "ffmpeg-missing",
|
|
437
|
+
children: [
|
|
438
|
+
{
|
|
439
|
+
type: "code",
|
|
440
|
+
text: "ffmpeg",
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
type: "text",
|
|
444
|
+
text: " is missing",
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("still slugs a heading without an anchor", () => {
|
|
451
|
+
const { blocks } = parse("## Find your line");
|
|
452
|
+
|
|
453
|
+
expect(blocks[0]).toMatchObject({
|
|
454
|
+
id: "find-your-line",
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("moves a later slug aside instead of taking an anchored id", () => {
|
|
459
|
+
const { blocks } = parse(
|
|
460
|
+
[
|
|
461
|
+
"## Rate limits {#rate-limited}",
|
|
462
|
+
"",
|
|
463
|
+
"## Rate limited",
|
|
464
|
+
].join("\n"),
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
expect(blocks.map((block) => (block.type === "heading" ? block.id : null))).toEqual([
|
|
468
|
+
"rate-limited",
|
|
469
|
+
"rate-limited-2",
|
|
470
|
+
]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("rejects an anchored id that is already used", () => {
|
|
474
|
+
expect(() =>
|
|
475
|
+
parse(
|
|
476
|
+
[
|
|
477
|
+
"## Rate limited",
|
|
478
|
+
"",
|
|
479
|
+
"## Too many requests {#rate-limited}",
|
|
480
|
+
].join("\n"),
|
|
481
|
+
),
|
|
482
|
+
).toThrow(/heading id "rate-limited" is already used/);
|
|
483
|
+
expect(() =>
|
|
484
|
+
parse(
|
|
485
|
+
[
|
|
486
|
+
"## One {#same}",
|
|
487
|
+
"",
|
|
488
|
+
"## Two {#same}",
|
|
489
|
+
].join("\n"),
|
|
490
|
+
),
|
|
491
|
+
).toThrow(/heading id "same" is already used/);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("rejects an id that is not lowercase letters, digits and hyphens", () => {
|
|
495
|
+
for (const id of [
|
|
496
|
+
"Token-Invalid",
|
|
497
|
+
"token_invalid",
|
|
498
|
+
"token invalid",
|
|
499
|
+
"-token",
|
|
500
|
+
"token--invalid",
|
|
501
|
+
"",
|
|
502
|
+
]) {
|
|
503
|
+
expect(() => parse(`## The token is invalid {#${id}}`)).toThrow(/must be lowercase letters, digits/);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("rejects an anchor with no heading text", () => {
|
|
508
|
+
expect(() => parse("## {#token-invalid}")).toThrow(/empty text run/);
|
|
509
|
+
});
|
|
510
|
+
});
|
package/src/guides/markdown.ts
CHANGED
|
@@ -52,6 +52,10 @@ const HEADING_2_PATTERN = /^##\s+(.+)$/;
|
|
|
52
52
|
|
|
53
53
|
const HEADING_3_PATTERN = /^###\s+(.+)$/;
|
|
54
54
|
|
|
55
|
+
const HEADING_ANCHOR_PATTERN = /^(.*?)\s*\{#([^}]*)\}$/;
|
|
56
|
+
|
|
57
|
+
const HEADING_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
58
|
+
|
|
55
59
|
const CALLOUT_OPEN_PATTERN = /^>\s*\[!(note|warning|danger)\]\s*(.*)$/;
|
|
56
60
|
|
|
57
61
|
const IMAGE_LINE_PATTERN = /^!\[([^\]]*)\]\(([^)\s]+)\)$/;
|
|
@@ -285,6 +289,20 @@ const headingId = (inlines: GuideInline[], used: Map<string, number>, fallback:
|
|
|
285
289
|
return count === 0 ? base : `${base}-${count + 1}`;
|
|
286
290
|
};
|
|
287
291
|
|
|
292
|
+
const anchoredHeadingId = (path: string, line: number, id: string, used: Map<string, number>) => {
|
|
293
|
+
if (!HEADING_ID_PATTERN.test(id)) {
|
|
294
|
+
fail(path, line, `heading id "${id}" must be lowercase letters, digits and single hyphens`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (used.has(id)) {
|
|
298
|
+
fail(path, line, `heading id "${id}" is already used in this guide`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
used.set(id, 1);
|
|
302
|
+
|
|
303
|
+
return id;
|
|
304
|
+
};
|
|
305
|
+
|
|
288
306
|
const parseOpenTarget = (path: string, line: number, value: string): GuideOpenTarget => {
|
|
289
307
|
const separator = value.indexOf(":");
|
|
290
308
|
const tab = separator === -1 ? value : value.slice(0, separator);
|
|
@@ -459,12 +477,16 @@ const scanLeafBlocks = (scanner: Scanner, nested: boolean): ParsedLeafBlock[] =>
|
|
|
459
477
|
fail(path, lineNumber, "unsupported heading depth; use ## or ###");
|
|
460
478
|
}
|
|
461
479
|
|
|
462
|
-
const
|
|
480
|
+
const text = match[1].trim();
|
|
481
|
+
const anchor = text.match(HEADING_ANCHOR_PATTERN);
|
|
482
|
+
const children = parseInlines(path, lineNumber, anchor ? (anchor[1] ?? "") : text);
|
|
463
483
|
|
|
464
484
|
blocks.push({
|
|
465
485
|
type: "heading",
|
|
466
486
|
level: h3 ? 3 : 2,
|
|
467
|
-
id:
|
|
487
|
+
id: anchor
|
|
488
|
+
? anchoredHeadingId(path, lineNumber, anchor[2] ?? "", scanner.headingIds)
|
|
489
|
+
: headingId(children, scanner.headingIds, `section-${scanner.headingIds.size + 1}`),
|
|
468
490
|
children,
|
|
469
491
|
});
|
|
470
492
|
|