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