|
| 1 | +<?php |
| 2 | +/** |
| 3 | + * PHPCSUtils, utility functions and classes for PHP_CodeSniffer sniff developers. |
| 4 | + * |
| 5 | + * @package PHPCSUtils |
| 6 | + * @copyright 2019-2020 PHPCSUtils Contributors |
| 7 | + * @license https://opensource.org/licenses/LGPL-3.0 LGPL3 |
| 8 | + * @link https://github.com/PHPCSStandards/PHPCSUtils |
| 9 | + */ |
| 10 | + |
| 11 | +namespace PHPCSUtils\GHPages; |
| 12 | + |
| 13 | +use RuntimeException; |
| 14 | + |
| 15 | +/** |
| 16 | + * Prepare markdown documents for use in a GH Pages website before deploy. |
| 17 | + * |
| 18 | + * {@internal This functionality has a minimum PHP requirement of PHP 7.2.} |
| 19 | + * |
| 20 | + * @internal |
| 21 | + * |
| 22 | + * @phpcs:disable PHPCompatibility.Classes.NewConstVisibility.Found |
| 23 | + * @phpcs:disable PHPCompatibility.FunctionDeclarations.NewParamTypeDeclarations.intFound |
| 24 | + * @phpcs:disable PHPCompatibility.FunctionDeclarations.NewParamTypeDeclarations.stringFound |
| 25 | + * @phpcs:disable PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations.intFound |
| 26 | + * @phpcs:disable PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations.stringFound |
| 27 | + * @phpcs:disable PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations.voidFound |
| 28 | + * @phpcs:disable PHPCompatibility.InitialValue.NewConstantScalarExpressions.constFound |
| 29 | + */ |
| 30 | +final class UpdateWebsite |
| 31 | +{ |
| 32 | + |
| 33 | + /** |
| 34 | + * Path to project root (without trailing slash). |
| 35 | + * |
| 36 | + * @var string |
| 37 | + */ |
| 38 | + private const PROJECT_ROOT = __DIR__ . '/../..'; |
| 39 | + |
| 40 | + /** |
| 41 | + * Relative path to target directory off project root (without trailing slash). |
| 42 | + * |
| 43 | + * @var string |
| 44 | + */ |
| 45 | + private const TARGET_DIR = 'docs'; |
| 46 | + |
| 47 | + /** |
| 48 | + * Frontmatter for the website homepage. |
| 49 | + * |
| 50 | + * @var string |
| 51 | + */ |
| 52 | + private const README_FRONTMATTER = '--- |
| 53 | +title: PHPCSUtils |
| 54 | +description: "PHPCSUtils: A suite of utility functions for use with PHP_CodeSniffer" |
| 55 | +anchor: home |
| 56 | +permalink: / |
| 57 | +seo: |
| 58 | + type: WebSite |
| 59 | + publisher: |
| 60 | + type: Organisation |
| 61 | +--- |
| 62 | +'; |
| 63 | + |
| 64 | + /** |
| 65 | + * Frontmatter for the changelog page. |
| 66 | + * |
| 67 | + * @var string |
| 68 | + */ |
| 69 | + private const CHANGELOG_FRONTMATTER = '--- |
| 70 | +title: Changelog |
| 71 | +description: "Changelog for the PHPCSUtils suite of utility functions for use with PHP_CodeSniffer" |
| 72 | +anchor: changelog |
| 73 | +permalink: /changelog |
| 74 | +seo: |
| 75 | + type: WebSite |
| 76 | + publisher: |
| 77 | + type: Organisation |
| 78 | +--- |
| 79 | +'; |
| 80 | + |
| 81 | + /** |
| 82 | + * Resolved path to project root (with trailing slash). |
| 83 | + * |
| 84 | + * @var string |
| 85 | + */ |
| 86 | + private $realRoot; |
| 87 | + |
| 88 | + /** |
| 89 | + * Resolved path to target directory (with trailing slash). |
| 90 | + * |
| 91 | + * @var string |
| 92 | + */ |
| 93 | + private $realTarget; |
| 94 | + |
| 95 | + /** |
| 96 | + * Run the transformation. |
| 97 | + * |
| 98 | + * @return int Exit code. |
| 99 | + */ |
| 100 | + public function run(): int |
| 101 | + { |
| 102 | + $exitcode = 0; |
| 103 | + |
| 104 | + try { |
| 105 | + $this->setPaths(); |
| 106 | + $this->transformReadme(); |
| 107 | + $this->transformChangelog(); |
| 108 | + } catch (RuntimeException $e) { |
| 109 | + echo 'ERROR: ', $e->getMessage(), \PHP_EOL; |
| 110 | + $exitcode = 1; |
| 111 | + } |
| 112 | + |
| 113 | + return $exitcode; |
| 114 | + } |
| 115 | + |
| 116 | + /** |
| 117 | + * Validate the paths to use. |
| 118 | + * |
| 119 | + * @return void |
| 120 | + */ |
| 121 | + private function setPaths(): void |
| 122 | + { |
| 123 | + $realRoot = \realpath(self::PROJECT_ROOT) . '/'; |
| 124 | + if ($realRoot === false) { |
| 125 | + throw new RuntimeException(\sprintf('Failed to find the %s directory.', $realRoot)); |
| 126 | + } |
| 127 | + |
| 128 | + $this->realRoot = $realRoot; |
| 129 | + |
| 130 | + // Check if the target directory exists and if not, create it. |
| 131 | + $targetDir = $this->realRoot . self::TARGET_DIR; |
| 132 | + |
| 133 | + if (@\is_dir($targetDir) === false) { |
| 134 | + if (@\mkdir($targetDir, 0777, true) === false) { |
| 135 | + throw new RuntimeException(\sprintf('Failed to create the %s directory.', $targetDir)); |
| 136 | + } |
| 137 | + } |
| 138 | + |
| 139 | + $realPath = \realpath($targetDir); |
| 140 | + if ($realPath === false) { |
| 141 | + throw new RuntimeException(\sprintf('Failed to find the %s directory.', $targetDir)); |
| 142 | + } |
| 143 | + |
| 144 | + $this->realTarget = $realPath . '/'; |
| 145 | + } |
| 146 | + |
| 147 | + /** |
| 148 | + * Apply various transformations to the index page. |
| 149 | + * |
| 150 | + * - Remove title, badges and index. |
| 151 | + * - Replace code samples with properly highlighted versions. |
| 152 | + * - Add frontmatter. |
| 153 | + * |
| 154 | + * @return void |
| 155 | + * |
| 156 | + * @throws \RuntimeException When any of the expected replacements could not be made. |
| 157 | + */ |
| 158 | + private function transformReadme(): void |
| 159 | + { |
| 160 | + $contents = $this->getContents($this->realRoot . 'README.md'); |
| 161 | + |
| 162 | + // Remove title, badges and index. |
| 163 | + $contents = $this->replace('`^.*## Features`s', '## Features', $contents, 1); |
| 164 | + |
| 165 | + // Remove the section about Non-Composer based integration. |
| 166 | + $contents = $this->replace( |
| 167 | + '`### Non-Composer based integration[\n\r]+(?:.+[\n\r]+)+?## Frequently Asked Questions`', |
| 168 | + '## Frequently Asked Questions', |
| 169 | + $contents, |
| 170 | + 1 |
| 171 | + ); |
| 172 | + |
| 173 | + // Replace installation instructions with properly highlighted version. |
| 174 | + $search = '~`{3}bash[\n\r]+composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer' |
| 175 | + . ' true[\n\r]+' |
| 176 | + . 'composer require phpcsstandards/phpcsutils:"([^\n\r]+)"[\n\r]+`{3}~'; |
| 177 | + $replace = '<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>' |
| 178 | + . 'composer config <span class="s">allow-plugins.dealerdirect/phpcodesniffer-composer-installer</span>' |
| 179 | + . ' <span class="mf">true</span>' |
| 180 | + . "\n" |
| 181 | + . 'composer require <span class="s">{{ site.phpcsutils.packagist }}</span>:"<span class="mf">$1</span>"' |
| 182 | + . "\n" |
| 183 | + . '</code></pre></div></div>'; |
| 184 | + $contents = $this->replace($search, $replace, $contents, 1); |
| 185 | + |
| 186 | + // Replace suggested end-user installation instructions with properly highlighted versions. |
| 187 | + $search = '~`{3}bash[\r\n]+> composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer' |
| 188 | + . ' true[\r\n]+> `{3}~'; |
| 189 | + $replace = '<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>' |
| 190 | + . 'composer config <span class="s">allow-plugins.dealerdirect/phpcodesniffer-composer-installer</span>' |
| 191 | + . ' <span class="mf">true</span>' |
| 192 | + . "\n" |
| 193 | + . '> </code></pre></div></div>'; |
| 194 | + $contents = $this->replace($search, $replace, $contents, 1); |
| 195 | + |
| 196 | + // Replace suggested end-user upgrade instructions with properly highlighted versions. |
| 197 | + $search = '~`{3}bash[\r\n]+> composer update your/cs-package --with-\[all-\]dependencies[\r\n]+> `{3}~'; |
| 198 | + $replace = '<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>' |
| 199 | + . 'composer update <span class="s">your/cs-package</span> <span class="mf">--with-[all-]dependencies</span>' |
| 200 | + . "\n" |
| 201 | + . '> </code></pre></div></div>'; |
| 202 | + $contents = $this->replace($search, $replace, $contents, 1); |
| 203 | + |
| 204 | + // Add frontmatter. |
| 205 | + $contents = self::README_FRONTMATTER . "\n" . $contents; |
| 206 | + |
| 207 | + $this->putContents($this->realTarget . 'index.md', $contents); |
| 208 | + } |
| 209 | + |
| 210 | + /** |
| 211 | + * Add frontmatter to the changelog page and remove "Unreleased". |
| 212 | + * |
| 213 | + * @return void |
| 214 | + */ |
| 215 | + private function transformChangelog(): void |
| 216 | + { |
| 217 | + $contents = $this->getContents($this->realRoot . 'CHANGELOG.md'); |
| 218 | + |
| 219 | + // Remove the section about Non-Composer based integration. |
| 220 | + $contents = $this->replace( |
| 221 | + '`## \[Unreleased\][\n\r]+(?:.+[\n\r]+)+?##`', |
| 222 | + '##', |
| 223 | + $contents, |
| 224 | + 1 |
| 225 | + ); |
| 226 | + |
| 227 | + // Add frontmatter. |
| 228 | + $contents = self::CHANGELOG_FRONTMATTER . "\n" . $contents; |
| 229 | + |
| 230 | + $this->putContents($this->realTarget . 'changelog.md', $contents); |
| 231 | + } |
| 232 | + |
| 233 | + /** |
| 234 | + * Execute a regex search and replace and verify the replacement was actually made. |
| 235 | + * |
| 236 | + * @param string $search The pattern to search for. |
| 237 | + * @param string $replace The replacement. |
| 238 | + * @param string $subject The string to execute the search & replace on. |
| 239 | + * @param int $limit Maximum number of replacements to make. |
| 240 | + * |
| 241 | + * @return string |
| 242 | + * |
| 243 | + * @throws \RuntimeException When the replacement was not made or not made the required number of times. |
| 244 | + */ |
| 245 | + private function replace(string $search, string $replace, string $subject, int $limit = 1): string |
| 246 | + { |
| 247 | + $subject = \preg_replace($search, $replace, $subject, $limit, $count); |
| 248 | + if ($count !== $limit) { |
| 249 | + throw new RuntimeException( |
| 250 | + 'Failed to make required replacement.' . \PHP_EOL |
| 251 | + . "Search regex: $search" . \PHP_EOL |
| 252 | + . "Replacements made: $count" |
| 253 | + ); |
| 254 | + } |
| 255 | + |
| 256 | + return $subject; |
| 257 | + } |
| 258 | + |
| 259 | + /** |
| 260 | + * Retrieve the contents of a file. |
| 261 | + * |
| 262 | + * @param string $source Path to the source file. |
| 263 | + * |
| 264 | + * @return string |
| 265 | + * |
| 266 | + * @throws \RuntimeException When the contents of the file could not be retrieved. |
| 267 | + */ |
| 268 | + private function getContents(string $source): string |
| 269 | + { |
| 270 | + $contents = \file_get_contents($source); |
| 271 | + if (!$contents) { |
| 272 | + throw new RuntimeException(\sprintf('Failed to read doc file: %s', $source)); |
| 273 | + } |
| 274 | + |
| 275 | + return $contents; |
| 276 | + } |
| 277 | + |
| 278 | + /** |
| 279 | + * Write a string to a file. |
| 280 | + * |
| 281 | + * @param string $target Path to the target file. |
| 282 | + * @param string $contents File contents to write. |
| 283 | + * |
| 284 | + * @return void |
| 285 | + * |
| 286 | + * @throws \RuntimeException When the target directory could not be created. |
| 287 | + * @throws \RuntimeException When the file could not be written to the target directory. |
| 288 | + */ |
| 289 | + private function putContents(string $target, string $contents): void |
| 290 | + { |
| 291 | + // Check if the target directory exists and if not, create it. |
| 292 | + $targetDir = \dirname($target); |
| 293 | + |
| 294 | + if (@\is_dir($targetDir) === false) { |
| 295 | + if (@\mkdir($targetDir, 0777, true) === false) { |
| 296 | + throw new RuntimeException(\sprintf('Failed to create the %s directory.', $targetDir)); |
| 297 | + } |
| 298 | + } |
| 299 | + |
| 300 | + // Make sure the file always ends on a new line. |
| 301 | + $contents = \rtrim($contents) . "\n"; |
| 302 | + if (\file_put_contents($target, $contents) === false) { |
| 303 | + throw new RuntimeException(\sprintf('Failed to write to target location: %s', $target)); |
| 304 | + } |
| 305 | + } |
| 306 | +} |
0 commit comments