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