Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
76 / 76
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
MarkdownRenderer
100.00% covered (success)
100.00%
76 / 76
100.00% covered (success)
100.00%
6 / 6
25
100.00% covered (success)
100.00%
1 / 1
 render
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 renderReleaseBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderRelease
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 renderReferences
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
5
 resolveTag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeRepositoryUrl
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Changelog\Renderer;
21
22use FastForward\DevTools\Changelog\Document\ChangelogDocument;
23use FastForward\DevTools\Changelog\Document\ChangelogRelease;
24use FastForward\DevTools\Changelog\Entry\ChangelogEntryType;
25use function Safe\preg_match;
26use function explode;
27use function implode;
28use function rtrim;
29use function str_ends_with;
30use function substr;
31use function trim;
32
33/**
34 * Renders Keep a Changelog markdown in a deterministic package-friendly format.
35 */
36  class MarkdownRenderer implements MarkdownRendererInterface
37{
38    private const string INTRODUCTION = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).";
39
40    /**
41     * Renders the full changelog markdown content.
42     *
43     * @param ChangelogDocument $document
44     * @param ?string $repositoryUrl
45     */
46    public function render(ChangelogDocument $document, ?string $repositoryUrl = null): string
47    {
48        $lines = explode("\n", self::INTRODUCTION);
49
50        foreach ($document->getReleases() as $release) {
51            if ('' !== $lines[array_key_last($lines)]) {
52                $lines[] = '';
53            }
54
55            $lines = [...$lines, ...$this->renderRelease($release)];
56        }
57
58        $references = $this->renderReferences($document, $repositoryUrl);
59
60        if ([] !== $references) {
61            if ('' !== $lines[array_key_last($lines)]) {
62                $lines[] = '';
63            }
64
65            $lines = [...$lines, ...$references];
66        }
67
68        return implode("\n", $lines) . "\n";
69    }
70
71    /**
72     * Renders only the body content of one released version.
73     *
74     * @param ChangelogRelease $release
75     */
76    public function renderReleaseBody(ChangelogRelease $release): string
77    {
78        return implode("\n", \array_slice($this->renderRelease($release), 2)) . "\n";
79    }
80
81    /**
82     * @param ChangelogRelease $release
83     *
84     * @return list<string>
85     */
86    private function renderRelease(ChangelogRelease $release): array
87    {
88        $heading = $release->isUnreleased()
89            ? \sprintf('## [%s]', ChangelogDocument::UNRELEASED_VERSION)
90            : (null === $release->getDate()
91                ? \sprintf('## [%s]', $release->getVersion())
92                : \sprintf('## [%s] - %s', $release->getVersion(), $release->getDate()));
93
94        $lines = [$heading, ''];
95        $renderedSections = 0;
96
97        foreach (ChangelogEntryType::ordered() as $type) {
98            $sectionEntries = $release->getEntriesFor($type);
99
100            if ([] === $sectionEntries) {
101                continue;
102            }
103
104            if (0 < $renderedSections) {
105                $lines[] = '';
106            }
107
108            $lines[] = '### ' . $type->value;
109            $lines[] = '';
110
111            foreach ($sectionEntries as $entry) {
112                $lines[] = '- ' . $entry;
113            }
114
115            ++$renderedSections;
116        }
117
118        return $lines;
119    }
120
121    /**
122     * @param ChangelogDocument $document
123     * @param ?string $repositoryUrl
124     *
125     * @return list<string>
126     */
127    private function renderReferences(ChangelogDocument $document, ?string $repositoryUrl): array
128    {
129        $normalizedRepositoryUrl = $this->normalizeRepositoryUrl($repositoryUrl);
130
131        if (null === $normalizedRepositoryUrl) {
132            return [];
133        }
134
135        $published = array_values(array_filter(
136            $document->getReleases(),
137            static fn(ChangelogRelease $release): bool => ! $release->isUnreleased(),
138        ));
139
140        if ([] === $published) {
141            return [];
142        }
143
144        $references = [
145            \sprintf(
146                '[unreleased]: %s/compare/%s...HEAD',
147                $normalizedRepositoryUrl,
148                $this->resolveTag($published[0]),
149            ),
150        ];
151
152        foreach ($published as $index => $release) {
153            $references[] = isset($published[$index + 1])
154                ? \sprintf(
155                    '[%s]: %s/compare/%s...%s',
156                    $release->getVersion(),
157                    $normalizedRepositoryUrl,
158                    $this->resolveTag($published[$index + 1]),
159                    $this->resolveTag($release),
160                )
161                : \sprintf(
162                    '[%s]: %s/releases/tag/%s',
163                    $release->getVersion(),
164                    $normalizedRepositoryUrl,
165                    $this->resolveTag($release),
166                );
167        }
168
169        return ['', ...$references];
170    }
171
172    /**
173     * Resolves the git tag name for a rendered release.
174     *
175     * @param ChangelogRelease $release
176     */
177    private function resolveTag(ChangelogRelease $release): string
178    {
179        return 'v' . $release->getVersion();
180    }
181
182    /**
183     * Normalizes repository URLs to the public HTTPS form expected by footer links.
184     *
185     * @param ?string $repositoryUrl
186     */
187    private function normalizeRepositoryUrl(?string $repositoryUrl): ?string
188    {
189        if (null === $repositoryUrl) {
190            return null;
191        }
192
193        $repositoryUrl = trim($repositoryUrl);
194
195        if ('' === $repositoryUrl) {
196            return null;
197        }
198
199        if (1 === preg_match('~^git@(?<host>[^:]+):(?<path>.+)$~', $repositoryUrl, $matches)) {
200            $repositoryUrl = 'https://' . $matches['host'] . '/' . $matches['path'];
201        }
202
203        if (1 === preg_match('~^ssh://git@(?<host>[^/]+)/(?<path>.+)$~', $repositoryUrl, $matches)) {
204            $repositoryUrl = 'https://' . $matches['host'] . '/' . $matches['path'];
205        }
206
207        if (str_ends_with($repositoryUrl, '.git')) {
208            $repositoryUrl = substr($repositoryUrl, 0, -4);
209        }
210
211        return rtrim($repositoryUrl, '/');
212    }
213}