packages/requirements/src/Config/Markdown/CardReader.php
1<?php
2
3declare(strict_types=1);
4
5namespace Requirements\Config\Markdown;
6
7use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
8use League\CommonMark\Node\Block\Document;
9use League\CommonMark\Node\Block\Paragraph;
10use League\CommonMark\Node\Node;
11use Requirements\Input\InvalidInputException;
12use stdClass;
13
14/**
15 * Reads the item cards of a Markdown definition body.
16 *
17 * A card is an ATX "# ID" heading followed by badge rows, one statement paragraph, evidence
18 * quotations and bold field sections, in that order.
19 */
20final class CardReader
21{
22 /**
23 * @param Presentation $presentation Receives the badges, citations and links of each card
24 * @param Citation|null $source Resolves citations against the definition's source
25 */
26 public function __construct(private readonly Presentation $presentation, private readonly ?Citation $source)
27 {
28 }
29
30 /**
31 * Reads every card of a document.
32 *
33 * @param Document $document The parsed body
34 * @param string $body The body text, used to check the heading syntax
35 * @param string $file The definition file
36 *
37 * @return list<stdClass> The items
38 *
39 * @throws InvalidInputException When a heading is not written as "# ID" or a card is malformed
40 */
41 public function cards(Document $document, string $body, string $file): array
42 {
43 $lines = explode("\n", $body);
44 $items = [];
45 $heading = null;
46 $blocks = [];
47 foreach ($document->children() as $node) {
48 if ($node instanceof Heading) {
49 if (preg_match('/^ {0,3}#[ \t]+/', $lines[($node->getStartLine() ?? 0) - 1] ?? '') !== 1) {
50 throw new InvalidInputException("$file: item headings must use ATX # ID syntax.");
51 }
52 if ($heading !== null) {
53 $items[] = $this->card($heading, $blocks, $file);
54 }
55 $heading = $node;
56 $blocks = [];
57 } else {
58 $blocks[] = $node;
59 }
60 }
61 if ($heading !== null) {
62 $items[] = $this->card($heading, $blocks, $file);
63 }
64 return $items;
65 }
66
67 /**
68 * Reads one card.
69 *
70 * @param Heading $heading The heading holding the item ID
71 * @param list<Node> $blocks The blocks up to the next heading
72 * @param string $file The definition file
73 *
74 * @return stdClass The item
75 *
76 * @throws InvalidInputException When the card lacks a statement or a badge, quotation or field is malformed
77 */
78 public function card(Heading $heading, array $blocks, string $file): stdClass
79 {
80 $id = Nodes::text($heading);
81 $item = new stdClass();
82 $item->id = $id;
83 $badges = new Badges();
84 while (isset($blocks[0]) && Badges::isParagraph($blocks[0])) {
85 $badges->read($blocks[0], $item);
86 array_shift($blocks);
87 }
88 $this->presentation->badges[$id] = $badges->images;
89 $statement = array_shift($blocks);
90 if (!$statement instanceof Paragraph || Nodes::field($statement) !== null) {
91 throw new InvalidInputException("$file: $id needs a statement paragraph after its badges.");
92 }
93 $item->statement = preg_replace('/\s*\n\s*/', ' ', Nodes::text($statement, true));
94 [$evidence, $blocks] = (new QuotationBlocks($this->presentation, $this->source))->read($blocks, $file, $id);
95 if ($evidence !== []) {
96 $item->evidence = $evidence;
97 }
98 (new FieldSections($this->presentation))->read($item, $blocks, $file, $id);
99 return $item;
100 }
101}
102