packages/requirements/src/Config/Markdown/Nodes.php

1<?php
2
3declare(strict_types=1);
4
5namespace Requirements\Config\Markdown;
6
7use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock;
8use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
9use League\CommonMark\Extension\CommonMark\Node\Inline\Code;
10use League\CommonMark\Extension\CommonMark\Node\Inline\Emphasis;
11use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
12use League\CommonMark\Extension\CommonMark\Node\Inline\Strong;
13use League\CommonMark\Node\Block\Paragraph;
14use League\CommonMark\Node\Inline\Newline;
15use League\CommonMark\Node\Inline\Text;
16use League\CommonMark\Node\Node;
17use Requirements\Input\InvalidInputException;
18
19/**
20 * Reads the CommonMark nodes that cards are built from, and escapes text written back.
21 */
22final class Nodes
23{
24    /**
25     * Returns the text of a node.
26     *
27     * @param Node $node The node
28     * @param bool $literals Whether code spans keep their backticks
29     *
30     * @return string The text of the node and its inline children
31     *
32     * @throws InvalidInputException When the node holds inline Markdown other than text, emphasis and code
33     */
34    public static function text(Node $node, bool $literals = false): string
35    {
36        if ($node instanceof Text) {
37            return $node->getLiteral();
38        }
39        if ($node instanceof Code) {
40            return $literals ? '`' . $node->getLiteral() . '`' : $node->getLiteral();
41        }
42        if ($node instanceof Newline) {
43            return "\n";
44        }
45        $result = '';
46        foreach ($node->children() as $child) {
47            if (!$child instanceof Text && !$child instanceof Code && !$child instanceof Newline && !$child instanceof Strong && !$child instanceof Emphasis) {
48                throw new InvalidInputException('Unsupported inline Markdown; use text, emphasis or code spans. Put links in reference or design fields.');
49            }
50            $result .= self::text($child, $literals);
51        }
52        return $result;
53    }
54
55    /**
56     * Returns the items of a bullet list.
57     *
58     * @param Node $node The list
59     *
60     * @return list<ListItem> The items
61     *
62     * @throws InvalidInputException When the node is not a bullet list
63     */
64    public static function items(Node $node): array
65    {
66        if (!$node instanceof ListBlock || $node->getListData()->type !== ListBlock::TYPE_BULLET) {
67            throw new InvalidInputException('Expected a bullet list.');
68        }
69        $items = [];
70        foreach ($node->children() as $item) {
71            if (!$item instanceof ListItem) {
72                throw new InvalidInputException('Expected a list item.');
73            }
74            $items[] = $item;
75        }
76        return $items;
77    }
78
79    /**
80     * Returns the single paragraph of a list item.
81     *
82     * @param Node $node The list item
83     *
84     * @return Paragraph The paragraph
85     *
86     * @throws InvalidInputException When the item holds anything else
87     */
88    public static function paragraph(Node $node): Paragraph
89    {
90        $child = $node->firstChild();
91        if (!$child instanceof Paragraph || $child->next() !== null) {
92            throw new InvalidInputException('Expected a single paragraph in this list item.');
93        }
94        return $child;
95    }
96
97    /**
98     * Returns the single link of a paragraph.
99     *
100     * @param Node $node The paragraph
101     *
102     * @return Link The link
103     *
104     * @throws InvalidInputException When the paragraph holds anything else or the destination is empty
105     */
106    public static function link(Node $node): Link
107    {
108        $link = $node->firstChild();
109        if (!$link instanceof Link || $link->next() !== null || $link->getUrl() === '') {
110            throw new InvalidInputException('Expected one Markdown link with a nonempty destination.');
111        }
112        return $link;
113    }
114
115    /**
116     * Returns the name of a bold field heading.
117     *
118     * @param Node $node The block
119     *
120     * @return string|null The lowercase field name, or null when the block is not a field heading
121     *
122     * @throws InvalidInputException When the bold text holds unsupported inline Markdown
123     */
124    public static function field(Node $node): ?string
125    {
126        $child = $node->firstChild();
127        return $node instanceof Paragraph && $child instanceof Strong && $child->next() === null ? strtolower(self::text($child)) : null;
128    }
129
130    /**
131     * Splits a "**name:** value" paragraph.
132     *
133     * @param Paragraph $node The paragraph
134     *
135     * @return array{string, string} The name and the trimmed value
136     *
137     * @throws InvalidInputException When the paragraph does not start with a bold name and a colon
138     */
139    public static function pair(Paragraph $node): array
140    {
141        $key = $node->firstChild();
142        if (!$key instanceof Strong) {
143            throw new InvalidInputException('Expected a bold field name followed by a colon.');
144        }
145        $label = self::text($key);
146        $text = substr(self::text($node), strlen($label));
147        if (str_ends_with($label, ':')) {
148            return [substr($label, 0, -1), trim($text)];
149        }
150        if (!str_starts_with(ltrim($text), ':')) {
151            throw new InvalidInputException('Expected a colon after the bold field name.');
152        }
153        return [$label, trim(substr(ltrim($text), 1))];
154    }
155
156    /**
157     * Escapes text so Markdown reads it back unchanged.
158     *
159     * @param string $text The text
160     *
161     * @return string The escaped text
162     */
163    public static function escape(string $text): string
164    {
165        $text = preg_replace('/([\\\\`*_\[\]<>&!])/', '\\\\$1', $text) ?? $text;
166        $text = preg_replace('/^(\s*)([#=+\-])/m', '$1\\\\$2', $text) ?? $text;
167        return preg_replace('/^(\s*[0-9]+)([.)])/m', '$1\\\\$2', $text) ?? $text;
168    }
169
170    /**
171     * Writes a link destination, in angle brackets when it contains spaces or brackets.
172     *
173     * @param string $url The destination
174     *
175     * @return string The Markdown destination
176     */
177    public static function destination(string $url): string
178    {
179        if (preg_match('/[<>()\\s]/', $url) !== 1) {
180            return $url;
181        }
182        return '<' . str_replace(['<', '>', "\n", "\r", ' '], ['%3C', '%3E', '%0A', '%0D', '%20'], $url) . '>';
183    }
184
185    /**
186     * Returns the heading anchor of an item ID.
187     *
188     * @param string $id The item ID
189     *
190     * @return string The lowercase ID without dots
191     */
192    public static function anchor(string $id): string
193    {
194        return strtolower(str_replace('.', '', $id));
195    }
196}
197