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;
25
26use function Safe\preg_match;
27use function explode;
28use function implode;
29use function rtrim;
30use function str_ends_with;
31use function substr;
32use function trim;
33
34/**
35 * Renders Keep a Changelog markdown in a deterministic package-friendly format.
36 */
37  class MarkdownRenderer implements MarkdownRendererInterface
38{
39    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).";
40
41    /**
42     * Renders the full changelog markdown content.
43     *
44     * @param ChangelogDocument $document
45     * @param ?string $repositoryUrl
46     */
47    public function render(ChangelogDocument $document, ?string $repositoryUrl = null): string
48    {
49        $lines = explode("\n", self::INTRODUCTION);
50
51        foreach ($document->getReleases() as $release) {
52            if ('' !== $lines[array_key_last($lines)]) {
53                $lines[] = '';
54            }
55
56            $lines = [...$lines, ...$this->renderRelease($release)];
57        }
58
59        $references = $this->renderReferences($document, $repositoryUrl);
60
61        if ([] !== $references) {
62            if ('' !== $lines[array_key_last($lines)]) {
63                $lines[] = '';
64            }
65
66            $lines = [...$lines, ...$references];
67        }
68
69        return implode("\n", $lines) . "\n";
70    }
71
72    /**
73     * Renders only the body content of one released version.
74     *
75     * @param ChangelogRelease $release
76     */
77    public function renderReleaseBody(ChangelogRelease $release): string
78    {
79        return implode("\n", \array_slice($this->renderRelease($release), 2)) . "\n";
80    }
81
82    /**
83     * @param ChangelogRelease $release
84     *
85     * @return list<string>
86     */
87    private function renderRelease(ChangelogRelease $release): array
88    {
89        $heading = $release->isUnreleased()
90            ? \sprintf('## [%s]', ChangelogDocument::UNRELEASED_VERSION)
91            : (null === $release->getDate()
92                ? \sprintf('## [%s]', $release->getVersion())
93                : \sprintf('## [%s] - %s', $release->getVersion(), $release->getDate()));
94
95        $lines = [$heading, ''];
96        $renderedSections = 0;
97
98        foreach (ChangelogEntryType::ordered() as $type) {
99            $sectionEntries = $release->getEntriesFor($type);
100
101            if ([] === $sectionEntries) {
102                continue;
103            }
104
105            if (0 < $renderedSections) {
106                $lines[] = '';
107            }
108
109            $lines[] = '### ' . $type->value;
110            $lines[] = '';
111
112            foreach ($sectionEntries as $entry) {
113                $lines[] = '- ' . $entry;
114            }
115
116            ++$renderedSections;
117        }
118
119        return $lines;
120    }
121
122    /**
123     * @param ChangelogDocument $document
124     * @param ?string $repositoryUrl
125     *
126     * @return list<string>
127     */
128    private function renderReferences(ChangelogDocument $document, ?string $repositoryUrl): array
129    {
130        $normalizedRepositoryUrl = $this->normalizeRepositoryUrl($repositoryUrl);
131
132        if (null === $normalizedRepositoryUrl) {
133            return [];
134        }
135
136        $published = array_values(array_filter(
137            $document->getReleases(),
138            static fn(ChangelogRelease $release): bool => ! $release->isUnreleased(),
139        ));
140
141        if ([] === $published) {
142            return [];
143        }
144
145        $references = [
146            \sprintf(
147                '[unreleased]: %s/compare/%s...HEAD',
148                $normalizedRepositoryUrl,
149                $this->resolveTag($published[0]),
150            ),
151        ];
152
153        foreach ($published as $index => $release) {
154            $references[] = isset($published[$index + 1])
155                ? \sprintf(
156                    '[%s]: %s/compare/%s...%s',
157                    $release->getVersion(),
158                    $normalizedRepositoryUrl,
159                    $this->resolveTag($published[$index + 1]),
160                    $this->resolveTag($release),
161                )
162                : \sprintf(
163                    '[%s]: %s/releases/tag/%s',
164                    $release->getVersion(),
165                    $normalizedRepositoryUrl,
166                    $this->resolveTag($release),
167                );
168        }
169
170        return ['', ...$references];
171    }
172
173    /**
174     * Resolves the git tag name for a rendered release.
175     *
176     * @param ChangelogRelease $release
177     */
178    private function resolveTag(ChangelogRelease $release): string
179    {
180        return 'v' . $release->getVersion();
181    }
182
183    /**
184     * Normalizes repository URLs to the public HTTPS form expected by footer links.
185     *
186     * @param ?string $repositoryUrl
187     */
188    private function normalizeRepositoryUrl(?string $repositoryUrl): ?string
189    {
190        if (null === $repositoryUrl) {
191            return null;
192        }
193
194        $repositoryUrl = trim($repositoryUrl);
195
196        if ('' === $repositoryUrl) {
197            return null;
198        }
199
200        if (1 === preg_match('~^git@(?<host>[^:]+):(?<path>.+)$~', $repositoryUrl, $matches)) {
201            $repositoryUrl = 'https://' . $matches['host'] . '/' . $matches['path'];
202        }
203
204        if (1 === preg_match('~^ssh://git@(?<host>[^/]+)/(?<path>.+)$~', $repositoryUrl, $matches)) {
205            $repositoryUrl = 'https://' . $matches['host'] . '/' . $matches['path'];
206        }
207
208        if (str_ends_with($repositoryUrl, '.git')) {
209            $repositoryUrl = substr($repositoryUrl, 0, -4);
210        }
211
212        return rtrim($repositoryUrl, '/');
213    }
214}