@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.
Files changed (59) hide show
  1. package/package.json +10 -3
  2. package/src/cli/main.ts +11 -1
  3. package/src/cli/schema.ts +62 -0
  4. package/src/cli/validate.ts +119 -36
  5. package/src/context/bridgeContext.ts +11 -1
  6. package/src/core/bridgeAction.ts +13 -0
  7. package/src/core/bridgeFailure.ts +16 -0
  8. package/src/core/bridgeKind.ts +2 -0
  9. package/src/core/bridgeNetError.ts +10 -0
  10. package/src/core/index.ts +2 -0
  11. package/src/guides/blocks.ts +167 -0
  12. package/src/guides/compile.ts +193 -0
  13. package/src/guides/compiled.ts +28 -0
  14. package/src/guides/index.ts +6 -0
  15. package/src/guides/markdown.test.ts +374 -0
  16. package/src/guides/markdown.ts +769 -0
  17. package/src/guides/variables.ts +52 -0
  18. package/src/guides/version.ts +1 -0
  19. package/src/main.ts +2 -1
  20. package/src/manifest/assets.ts +4 -4
  21. package/src/manifest/guide.ts +23 -18
  22. package/src/manifest/index.ts +3 -2
  23. package/src/manifest/jsonSchema.ts +16 -0
  24. package/src/manifest/localized.ts +7 -0
  25. package/src/manifest/manifest.test.ts +366 -0
  26. package/src/manifest/manifest.ts +354 -120
  27. package/src/manifest/primitives.ts +63 -0
  28. package/src/manifest/source.ts +5 -4
  29. package/src/modules/bridge.ts +19 -3
  30. package/src/modules/bridgeDriver.ts +4 -0
  31. package/src/modules/bridgeModule.ts +22 -24
  32. package/src/panel/bridgeAccess.ts +3 -0
  33. package/src/panel/bridgePanel.ts +6 -0
  34. package/src/panel/index.ts +1 -0
  35. package/src/protocol/bridgeCatalog.ts +56 -0
  36. package/src/protocol/bridgeEgress.ts +53 -0
  37. package/src/protocol/bridgeFrame.ts +17 -1
  38. package/src/protocol/bridgeLine.ts +41 -6
  39. package/src/protocol/bridgeManifest.ts +15 -1
  40. package/src/protocol/bridgeVersion.ts +1 -1
  41. package/src/protocol/index.ts +3 -0
  42. package/src/runtime/bridgeContext.ts +29 -9
  43. package/src/runtime/bridgeDriver.ts +1 -0
  44. package/src/runtime/bridgeEgress.ts +360 -0
  45. package/src/runtime/bridgeExec.ts +15 -5
  46. package/src/runtime/bridgeFiles.ts +58 -21
  47. package/src/runtime/bridgeLog.ts +26 -3
  48. package/src/runtime/bridgeNet.ts +75 -22
  49. package/src/runtime/bridgeRegistry.ts +51 -6
  50. package/src/runtime/bridgeServer.ts +48 -1
  51. package/src/runtime/bridgeSupervisor.ts +6 -6
  52. package/src/runtime/index.ts +0 -17
  53. package/src/runtime/serve.ts +44 -3
  54. package/src/terminal/bridgeTerminalArg.ts +11 -0
  55. package/src/terminal/bridgeTerminalCommand.ts +11 -0
  56. package/src/terminal/bridgeTerminalLevel.ts +6 -0
  57. package/src/terminal/bridgeTerminalRule.ts +8 -0
  58. package/src/terminal/index.ts +4 -0
  59. package/src/manifest/checks.ts +0 -50
@@ -0,0 +1,769 @@
1
+ import {
2
+ GUIDE_COMMAND_MAX_LENGTH,
3
+ GuideCalloutTone,
4
+ type GuideInline,
5
+ type GuideLeafBlock,
6
+ GuideOpenTab,
7
+ type GuideOpenTarget,
8
+ guideOpenTargetSchema,
9
+ zGuideCommand,
10
+ } from "./blocks";
11
+ import { isGuideVariableKey, isPanelVariableKey } from "./variables";
12
+
13
+ const CALLOUT_TONES: Record<string, GuideCalloutTone> = {
14
+ note: GuideCalloutTone.Note,
15
+ warning: GuideCalloutTone.Warning,
16
+ danger: GuideCalloutTone.Danger,
17
+ };
18
+
19
+ export interface ParsedImage {
20
+ type: "image";
21
+ path: string;
22
+ alt: string;
23
+ }
24
+
25
+ export type ParsedLeafBlock =
26
+ | Exclude<
27
+ GuideLeafBlock,
28
+ {
29
+ type: "image";
30
+ }
31
+ >
32
+ | ParsedImage;
33
+
34
+ export interface ParsedConditional {
35
+ type: "conditional";
36
+ key: string;
37
+ when: ParsedLeafBlock[];
38
+ otherwise: ParsedLeafBlock[];
39
+ }
40
+
41
+ export type ParsedBlock = ParsedLeafBlock | ParsedConditional;
42
+
43
+ export interface ParsedGuideBody {
44
+ blocks: ParsedBlock[];
45
+ images: string[];
46
+ commands: string[];
47
+ }
48
+
49
+ const FENCE_PATTERN = /^```([a-z0-9-]*)$/;
50
+
51
+ const HEADING_2_PATTERN = /^##\s+(.+)$/;
52
+
53
+ const HEADING_3_PATTERN = /^###\s+(.+)$/;
54
+
55
+ const CALLOUT_OPEN_PATTERN = /^>\s*\[!(note|warning|danger)\]\s*(.*)$/;
56
+
57
+ const IMAGE_LINE_PATTERN = /^!\[([^\]]*)\]\(([^)\s]+)\)$/;
58
+
59
+ const IMAGE_PATH_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9/_-]*\.webp$/;
60
+
61
+ const DIRECTIVE_LINE_PATTERN = /^@\[([a-z]+)\]\((.*)\)$/;
62
+
63
+ const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
64
+
65
+ const UNORDERED_ITEM_PATTERN = /^-\s+(.+)$/;
66
+
67
+ const ORDERED_ITEM_PATTERN = /^\d{1,3}\.\s+(.+)$/;
68
+
69
+ const RULE_PATTERN = /^(-{3,}|\*{3,}|_{3,})$/;
70
+
71
+ const RAW_HTML_PATTERN = /<[a-zA-Z!/]/;
72
+
73
+ const INLINE_IMAGE_PATTERN = /^!\[[^\]]*\]\([^)\s]+\)/;
74
+
75
+ const LINK_PATTERN = /^\[([^\]]+)\]\(([^)\s]+)\)/;
76
+
77
+ const LINK_HREF_PATTERN = /^(https:\/\/|\/)/;
78
+
79
+ const CONDITIONAL_OPEN_PATTERN = /^:::when\s+(\S+)$/;
80
+
81
+ const CONDITIONAL_ELSE = ":::else";
82
+
83
+ const CONDITIONAL_CLOSE = ":::";
84
+
85
+ const fail: (path: string, line: number, message: string) => never = (path, line, message) => {
86
+ throw new Error(`${path}:${line} ${message}`);
87
+ };
88
+
89
+ const checkVariableRef = (path: string, line: number, key: string) => {
90
+ if (!isGuideVariableKey(key) && !isPanelVariableKey(key)) {
91
+ fail(path, line, `unknown variable "${key}"`);
92
+ }
93
+
94
+ return key;
95
+ };
96
+
97
+ const parseInlines = (path: string, line: number, text: string): GuideInline[] => {
98
+ if (RAW_HTML_PATTERN.test(text)) {
99
+ fail(path, line, "raw HTML is not supported");
100
+ }
101
+
102
+ const inlines: GuideInline[] = [];
103
+
104
+ let plain = "";
105
+ let index = 0;
106
+
107
+ const flush = () => {
108
+ if (plain.length > 0) {
109
+ inlines.push({
110
+ type: "text",
111
+ text: plain,
112
+ });
113
+
114
+ plain = "";
115
+ }
116
+ };
117
+
118
+ while (index < text.length) {
119
+ if (text.startsWith("{{", index)) {
120
+ const end = text.indexOf("}}", index + 2);
121
+
122
+ if (end === -1) {
123
+ fail(path, line, "unterminated {{ marker");
124
+ }
125
+
126
+ const key = text.slice(index + 2, end).trim();
127
+
128
+ if (key.length === 0) {
129
+ fail(path, line, "empty {{ marker");
130
+ }
131
+
132
+ checkVariableRef(path, line, key);
133
+
134
+ flush();
135
+ inlines.push({
136
+ type: "variable",
137
+ key,
138
+ });
139
+
140
+ index = end + 2;
141
+
142
+ continue;
143
+ }
144
+
145
+ if (text.startsWith("**", index)) {
146
+ const end = text.indexOf("**", index + 2);
147
+
148
+ if (end === -1) {
149
+ fail(path, line, "unterminated ** marker");
150
+ }
151
+
152
+ const inner = text.slice(index + 2, end);
153
+
154
+ if (inner.trim().length === 0) {
155
+ fail(path, line, "empty ** marker");
156
+ }
157
+
158
+ flush();
159
+ inlines.push({
160
+ type: "bold",
161
+ text: inner,
162
+ });
163
+
164
+ index = end + 2;
165
+
166
+ continue;
167
+ }
168
+
169
+ const char = text[index];
170
+
171
+ if (char === "*") {
172
+ const end = text.indexOf("*", index + 1);
173
+
174
+ if (end === -1) {
175
+ fail(path, line, "unterminated * marker");
176
+ }
177
+
178
+ const inner = text.slice(index + 1, end);
179
+
180
+ if (inner.trim().length === 0) {
181
+ fail(path, line, "empty * marker");
182
+ }
183
+
184
+ flush();
185
+ inlines.push({
186
+ type: "italic",
187
+ text: inner,
188
+ });
189
+
190
+ index = end + 1;
191
+
192
+ continue;
193
+ }
194
+
195
+ if (char === "`") {
196
+ const end = text.indexOf("`", index + 1);
197
+
198
+ if (end === -1) {
199
+ fail(path, line, "unterminated ` marker");
200
+ }
201
+
202
+ const inner = text.slice(index + 1, end);
203
+
204
+ if (inner.length === 0) {
205
+ fail(path, line, "empty ` marker");
206
+ }
207
+
208
+ flush();
209
+ inlines.push({
210
+ type: "code",
211
+ text: inner,
212
+ });
213
+
214
+ index = end + 1;
215
+
216
+ continue;
217
+ }
218
+
219
+ if (char === "!" && INLINE_IMAGE_PATTERN.test(text.slice(index))) {
220
+ fail(path, line, "images must stand on their own line");
221
+ }
222
+
223
+ if (char === "[") {
224
+ const match = text.slice(index).match(LINK_PATTERN);
225
+
226
+ if (!match) {
227
+ fail(path, line, "unterminated link");
228
+ }
229
+
230
+ const [full, label, href] = match;
231
+
232
+ if (!label || !href || !full) {
233
+ fail(path, line, "malformed link");
234
+ }
235
+
236
+ if (!LINK_HREF_PATTERN.test(href)) {
237
+ fail(path, line, `link "${href}" must start with https:// or /`);
238
+ }
239
+
240
+ if (href.includes("{{")) {
241
+ fail(path, line, "variables cannot be used inside a link target");
242
+ }
243
+
244
+ flush();
245
+ inlines.push({
246
+ type: "link",
247
+ href,
248
+ text: label,
249
+ });
250
+
251
+ index += full.length;
252
+
253
+ continue;
254
+ }
255
+
256
+ plain += char;
257
+ index += 1;
258
+ }
259
+
260
+ flush();
261
+
262
+ if (inlines.length === 0) {
263
+ fail(path, line, "empty text run");
264
+ }
265
+
266
+ return inlines;
267
+ };
268
+
269
+ const inlineText = (inlines: GuideInline[]) => {
270
+ return inlines.map((inline) => (inline.type === "variable" ? "" : inline.text)).join(" ");
271
+ };
272
+
273
+ const headingId = (inlines: GuideInline[], used: Map<string, number>, fallback: string) => {
274
+ const base =
275
+ inlineText(inlines)
276
+ .toLowerCase()
277
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
278
+ .replace(/^-+|-+$/g, "")
279
+ .slice(0, 96) || fallback;
280
+
281
+ const count = used.get(base) ?? 0;
282
+
283
+ used.set(base, count + 1);
284
+
285
+ return count === 0 ? base : `${base}-${count + 1}`;
286
+ };
287
+
288
+ const parseOpenTarget = (path: string, line: number, value: string): GuideOpenTarget => {
289
+ const separator = value.indexOf(":");
290
+ const tab = separator === -1 ? value : value.slice(0, separator);
291
+ const argument = separator === -1 ? null : value.slice(separator + 1);
292
+
293
+ if (tab === GuideOpenTab.Panel) {
294
+ if (!argument) {
295
+ fail(path, line, "panel target needs a tab id, for example panel:settings");
296
+ }
297
+
298
+ return guideOpenTargetSchema.parse({
299
+ tab,
300
+ tabId: argument,
301
+ });
302
+ }
303
+
304
+ if (tab === GuideOpenTab.Files) {
305
+ return guideOpenTargetSchema.parse({
306
+ tab,
307
+ path: argument,
308
+ });
309
+ }
310
+
311
+ if (argument !== null) {
312
+ fail(path, line, `"${tab}" target does not take an argument`);
313
+ }
314
+
315
+ if (tab !== GuideOpenTab.Overview && tab !== GuideOpenTab.Console && tab !== GuideOpenTab.Backups) {
316
+ fail(path, line, `unknown open target "${value}"`);
317
+ }
318
+
319
+ return guideOpenTargetSchema.parse({
320
+ tab,
321
+ });
322
+ };
323
+
324
+ const isBlockStart = (line: string) => {
325
+ return (
326
+ line.startsWith("#")
327
+ || line.startsWith("```")
328
+ || line.startsWith(">")
329
+ || line.startsWith("|")
330
+ || line.startsWith(":::")
331
+ || RULE_PATTERN.test(line)
332
+ || UNORDERED_ITEM_PATTERN.test(line)
333
+ || ORDERED_ITEM_PATTERN.test(line)
334
+ || IMAGE_LINE_PATTERN.test(line)
335
+ || DIRECTIVE_LINE_PATTERN.test(line)
336
+ );
337
+ };
338
+
339
+ interface Scanner {
340
+ path: string;
341
+ lines: string[];
342
+ index: number;
343
+ headingIds: Map<string, number>;
344
+ images: string[];
345
+ commands: string[];
346
+ }
347
+
348
+ const scanLeafBlocks = (scanner: Scanner, nested: boolean): ParsedLeafBlock[] => {
349
+ const { path } = scanner;
350
+ const blocks: ParsedLeafBlock[] = [];
351
+
352
+ while (scanner.index < scanner.lines.length) {
353
+ const raw = scanner.lines[scanner.index];
354
+
355
+ if (raw === undefined) {
356
+ break;
357
+ }
358
+
359
+ const lineNumber = scanner.index + 1;
360
+ const line = raw.trimEnd();
361
+
362
+ if (line.trim().length === 0) {
363
+ scanner.index += 1;
364
+
365
+ continue;
366
+ }
367
+
368
+ if (/^\s/.test(line)) {
369
+ fail(path, lineNumber, "indented blocks are not supported");
370
+ }
371
+
372
+ if (line === CONDITIONAL_ELSE || line === CONDITIONAL_CLOSE) {
373
+ if (!nested) {
374
+ fail(path, lineNumber, `unexpected ${line} without a matching :::when`);
375
+ }
376
+
377
+ break;
378
+ }
379
+
380
+ if (CONDITIONAL_OPEN_PATTERN.test(line)) {
381
+ if (nested) {
382
+ fail(path, lineNumber, "conditionals cannot be nested");
383
+ }
384
+
385
+ break;
386
+ }
387
+
388
+ if (line.startsWith(":::")) {
389
+ fail(path, lineNumber, "conditionals open with :::when <variable>");
390
+ }
391
+
392
+ if (line.startsWith("```")) {
393
+ const fence = line.match(FENCE_PATTERN);
394
+
395
+ if (!fence) {
396
+ fail(path, lineNumber, "malformed code fence");
397
+ }
398
+
399
+ const language = fence[1] ? fence[1] : null;
400
+ const content: string[] = [];
401
+
402
+ scanner.index += 1;
403
+
404
+ let closed = false;
405
+
406
+ while (scanner.index < scanner.lines.length) {
407
+ const codeLine = scanner.lines[scanner.index];
408
+
409
+ if (codeLine === undefined) {
410
+ break;
411
+ }
412
+
413
+ if (codeLine.trimEnd() === "```") {
414
+ closed = true;
415
+ scanner.index += 1;
416
+
417
+ break;
418
+ }
419
+
420
+ content.push(codeLine);
421
+ scanner.index += 1;
422
+ }
423
+
424
+ if (!closed) {
425
+ fail(path, lineNumber, "unterminated code fence");
426
+ }
427
+
428
+ const body = content.join("\n").trimEnd();
429
+
430
+ if (body.length === 0) {
431
+ fail(path, lineNumber, "empty code fence");
432
+ }
433
+
434
+ blocks.push({
435
+ type: "code",
436
+ language,
437
+ content: body,
438
+ });
439
+
440
+ continue;
441
+ }
442
+
443
+ if (line.startsWith("#")) {
444
+ const h3 = line.match(HEADING_3_PATTERN);
445
+ const h2 = h3 ? null : line.match(HEADING_2_PATTERN);
446
+ const match = h3 ?? h2;
447
+
448
+ if (!match?.[1]) {
449
+ if (/^#\s/.test(line)) {
450
+ fail(path, lineNumber, "h1 is not supported; the guide title comes from serverk.yml");
451
+ }
452
+
453
+ fail(path, lineNumber, "unsupported heading depth; use ## or ###");
454
+ }
455
+
456
+ const children = parseInlines(path, lineNumber, match[1].trim());
457
+
458
+ blocks.push({
459
+ type: "heading",
460
+ level: h3 ? 3 : 2,
461
+ id: headingId(children, scanner.headingIds, `section-${scanner.headingIds.size + 1}`),
462
+ children,
463
+ });
464
+
465
+ scanner.index += 1;
466
+
467
+ continue;
468
+ }
469
+
470
+ if (RULE_PATTERN.test(line)) {
471
+ fail(path, lineNumber, "horizontal rules are not supported");
472
+ }
473
+
474
+ if (line.startsWith("|")) {
475
+ fail(path, lineNumber, "tables are not supported");
476
+ }
477
+
478
+ if (line.startsWith(">")) {
479
+ const open = line.match(CALLOUT_OPEN_PATTERN);
480
+
481
+ if (!open?.[1]) {
482
+ fail(path, lineNumber, "blockquotes must open with > [!note], > [!warning] or > [!danger]");
483
+ }
484
+
485
+ const parts: string[] = [];
486
+
487
+ if (open[2]) {
488
+ parts.push(open[2]);
489
+ }
490
+
491
+ scanner.index += 1;
492
+
493
+ while (scanner.index < scanner.lines.length) {
494
+ const quoted = scanner.lines[scanner.index]?.trimEnd();
495
+
496
+ if (quoted === undefined || !quoted.startsWith(">")) {
497
+ break;
498
+ }
499
+
500
+ parts.push(quoted.replace(/^>\s?/, ""));
501
+ scanner.index += 1;
502
+ }
503
+
504
+ const content = parts.join(" ").trim();
505
+
506
+ if (content.length === 0) {
507
+ fail(path, lineNumber, "empty callout");
508
+ }
509
+
510
+ const tone = CALLOUT_TONES[open[1]];
511
+
512
+ if (!tone) {
513
+ fail(path, lineNumber, "unknown callout tone");
514
+ }
515
+
516
+ blocks.push({
517
+ type: "callout",
518
+ tone,
519
+ children: parseInlines(path, lineNumber, content),
520
+ });
521
+
522
+ continue;
523
+ }
524
+
525
+ const image = line.match(IMAGE_LINE_PATTERN);
526
+
527
+ if (image) {
528
+ const [, alt, imagePath] = image;
529
+
530
+ if (imagePath === undefined || alt === undefined) {
531
+ fail(path, lineNumber, "malformed image");
532
+ }
533
+
534
+ if (!IMAGE_PATH_PATTERN.test(imagePath)) {
535
+ fail(path, lineNumber, `image "${imagePath}" must be a relative .webp path`);
536
+ }
537
+
538
+ blocks.push({
539
+ type: "image",
540
+ path: imagePath,
541
+ alt,
542
+ });
543
+ scanner.images.push(imagePath);
544
+
545
+ scanner.index += 1;
546
+
547
+ continue;
548
+ }
549
+
550
+ const directive = line.match(DIRECTIVE_LINE_PATTERN);
551
+
552
+ if (directive) {
553
+ const [, name, value] = directive;
554
+
555
+ if (!name || value === undefined) {
556
+ fail(path, lineNumber, "malformed directive");
557
+ }
558
+
559
+ switch (name) {
560
+ case "youtube": {
561
+ if (!YOUTUBE_ID_PATTERN.test(value)) {
562
+ fail(path, lineNumber, `"${value}" is not a YouTube video id`);
563
+ }
564
+
565
+ blocks.push({
566
+ type: "youtube",
567
+ videoId: value,
568
+ });
569
+
570
+ break;
571
+ }
572
+ case "field": {
573
+ blocks.push({
574
+ type: "field",
575
+ key: checkVariableRef(path, lineNumber, value.trim()),
576
+ });
577
+
578
+ break;
579
+ }
580
+ case "command": {
581
+ const command = zGuideCommand.safeParse(value.trim());
582
+
583
+ if (!command.success) {
584
+ fail(path, lineNumber, `a command must be a single line of 1 to ${GUIDE_COMMAND_MAX_LENGTH} characters`);
585
+ }
586
+
587
+ blocks.push({
588
+ type: "command",
589
+ command: command.data,
590
+ });
591
+ scanner.commands.push(command.data);
592
+
593
+ break;
594
+ }
595
+ case "open": {
596
+ blocks.push({
597
+ type: "open",
598
+ target: parseOpenTarget(path, lineNumber, value.trim()),
599
+ });
600
+
601
+ break;
602
+ }
603
+ default: {
604
+ fail(path, lineNumber, `unknown directive @[${name}]`);
605
+ }
606
+ }
607
+
608
+ scanner.index += 1;
609
+
610
+ continue;
611
+ }
612
+
613
+ if (line.startsWith("![") || line.startsWith("@[")) {
614
+ fail(path, lineNumber, "malformed image or directive line");
615
+ }
616
+
617
+ const unordered = line.match(UNORDERED_ITEM_PATTERN);
618
+ const ordered = unordered ? null : line.match(ORDERED_ITEM_PATTERN);
619
+
620
+ if (unordered || ordered) {
621
+ const pattern = unordered ? UNORDERED_ITEM_PATTERN : ORDERED_ITEM_PATTERN;
622
+ const items: GuideInline[][] = [];
623
+
624
+ while (scanner.index < scanner.lines.length) {
625
+ const itemLine = scanner.lines[scanner.index]?.trimEnd();
626
+
627
+ if (itemLine === undefined) {
628
+ break;
629
+ }
630
+
631
+ const item = itemLine.match(pattern);
632
+
633
+ if (!item?.[1]) {
634
+ break;
635
+ }
636
+
637
+ items.push(parseInlines(path, scanner.index + 1, item[1].trim()));
638
+ scanner.index += 1;
639
+ }
640
+
641
+ blocks.push({
642
+ type: "list",
643
+ ordered: Boolean(ordered),
644
+ items,
645
+ });
646
+
647
+ continue;
648
+ }
649
+
650
+ const parts: string[] = [
651
+ line,
652
+ ];
653
+
654
+ scanner.index += 1;
655
+
656
+ while (scanner.index < scanner.lines.length) {
657
+ const next = scanner.lines[scanner.index]?.trimEnd();
658
+
659
+ if (next === undefined || next.trim().length === 0 || isBlockStart(next)) {
660
+ break;
661
+ }
662
+
663
+ if (/^\s/.test(next)) {
664
+ fail(path, scanner.index + 1, "indented blocks are not supported");
665
+ }
666
+
667
+ parts.push(next);
668
+ scanner.index += 1;
669
+ }
670
+
671
+ blocks.push({
672
+ type: "paragraph",
673
+ children: parseInlines(path, lineNumber, parts.join(" ")),
674
+ });
675
+ }
676
+
677
+ return blocks;
678
+ };
679
+
680
+ const scanBlocks = (scanner: Scanner): ParsedBlock[] => {
681
+ const { path } = scanner;
682
+ const blocks: ParsedBlock[] = [];
683
+
684
+ while (scanner.index < scanner.lines.length) {
685
+ const before = scanner.index;
686
+
687
+ blocks.push(...scanLeafBlocks(scanner, false));
688
+
689
+ if (scanner.index >= scanner.lines.length) {
690
+ break;
691
+ }
692
+
693
+ const lineNumber = scanner.index + 1;
694
+ const line = scanner.lines[scanner.index]?.trimEnd() ?? "";
695
+ const open = line.match(CONDITIONAL_OPEN_PATTERN);
696
+
697
+ if (!open?.[1]) {
698
+ if (scanner.index === before) {
699
+ fail(path, lineNumber, "unexpected line");
700
+ }
701
+
702
+ continue;
703
+ }
704
+
705
+ const key = checkVariableRef(path, lineNumber, open[1]);
706
+
707
+ scanner.index += 1;
708
+
709
+ const when = scanLeafBlocks(scanner, true);
710
+ const branch = scanner.lines[scanner.index]?.trimEnd();
711
+
712
+ if (branch === undefined) {
713
+ fail(path, lineNumber, "unterminated :::when block");
714
+ }
715
+
716
+ let otherwise: ParsedLeafBlock[] = [];
717
+
718
+ if (branch === CONDITIONAL_ELSE) {
719
+ scanner.index += 1;
720
+ otherwise = scanLeafBlocks(scanner, true);
721
+
722
+ const close = scanner.lines[scanner.index]?.trimEnd();
723
+
724
+ if (close !== CONDITIONAL_CLOSE) {
725
+ fail(path, lineNumber, "unterminated :::when block");
726
+ }
727
+ } else if (branch !== CONDITIONAL_CLOSE) {
728
+ fail(path, lineNumber, "unterminated :::when block");
729
+ }
730
+
731
+ scanner.index += 1;
732
+
733
+ if (when.length === 0) {
734
+ fail(path, lineNumber, "empty :::when branch");
735
+ }
736
+
737
+ blocks.push({
738
+ type: "conditional",
739
+ key,
740
+ when,
741
+ otherwise,
742
+ });
743
+ }
744
+
745
+ return blocks;
746
+ };
747
+
748
+ export const parseGuideMarkdown = (path: string, text: string): ParsedGuideBody => {
749
+ const scanner: Scanner = {
750
+ path,
751
+ lines: text.replace(/^/, "").split(/\r?\n/),
752
+ index: 0,
753
+ headingIds: new Map(),
754
+ images: [],
755
+ commands: [],
756
+ };
757
+
758
+ const blocks = scanBlocks(scanner);
759
+
760
+ if (blocks.length === 0) {
761
+ fail(path, 1, "guide body is empty");
762
+ }
763
+
764
+ return {
765
+ blocks,
766
+ images: scanner.images,
767
+ commands: scanner.commands,
768
+ };
769
+ };