Skip to content

Commit

Permalink
Generate the documentation instead of committing it - step 2
Browse files Browse the repository at this point in the history
This commit adds the following:
* A GH Actions workflow.
    This workflow will:
    - Generate the files needed to create the website (phpDoc documentation, conversion of the README and CHANGELOG to the format desired for the website) and put them in the `docs` directory.
    - Build the Jekyll site based on the files in the `docs` directory.
        Both the configuration and structure files which live in the repo, as well as the generated files are used for this.
    - Deploy the website.
* Three script files in the `.github/GHPages` directory to be used during the build process to:
    - Add the version number of the current release to the generated documentation.
    - Transform the README and CHANGELOG files to a format suitable for use in the website.
* A `README.md` file to the `docs` directory to document how the documentation generation works and how to send in updates for the website.

Additionally, the following changes were made:
* Until now, the website did not contain the Changelog. As this will now change, a link to the changelog is added to the sidebar.
* The version number of the release on which the documentation was based was not previously listed on the documentation pages.
    The phpDocumentor configuration will be updated as part of the workflow run to add the version number (via one of the scripts).
* As the website will now be automatically generated, the checklist in the `release-checklist.md` file has been updated to match the new process.
* As the scripts in the `.github/GHPages` directory are not part of the package, but effectively "dev-tools", these scripts have been written with a PHP 7.2 minimum in mind. The CI PHP linting commands have been updated to allow for this.

Notes:
* The GH Actions workflow will do a dry-run when any of the files involved in the build process are changed.
* The GH Actions workflow will do an actual deploy when a new release is published, as well as on request by triggering the workflow.

The `ghpages.yml` workflow is can now be removed. That workflow was used to test whether the site could be build correctly, but is no longer needed now the `update-docs.yml` workflow does a dry-run whenever relevant.
  • Loading branch information
jrfnl committed Jan 3, 2023
1 parent 904fa67 commit fbbe7c5
Show file tree
Hide file tree
Showing 12 changed files with 611 additions and 62 deletions.
306 changes: 306 additions & 0 deletions .github/GHPages/UpdateWebsite.php
@@ -0,0 +1,306 @@
<?php
/**
* PHPCSUtils, utility functions and classes for PHP_CodeSniffer sniff developers.
*
* @package PHPCSUtils
* @copyright 2019-2020 PHPCSUtils Contributors
* @license https://opensource.org/licenses/LGPL-3.0 LGPL3
* @link https://github.com/PHPCSStandards/PHPCSUtils
*/

namespace PHPCSUtils\GHPages;

use RuntimeException;

/**
* Prepare markdown documents for use in a GH Pages website before deploy.
*
* {@internal This functionality has a minimum PHP requirement of PHP 7.2.}
*
* @internal
*
* @phpcs:disable PHPCompatibility.Classes.NewConstVisibility.Found
* @phpcs:disable PHPCompatibility.FunctionDeclarations.NewParamTypeDeclarations.intFound
* @phpcs:disable PHPCompatibility.FunctionDeclarations.NewParamTypeDeclarations.stringFound
* @phpcs:disable PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations.intFound
* @phpcs:disable PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations.stringFound
* @phpcs:disable PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations.voidFound
* @phpcs:disable PHPCompatibility.InitialValue.NewConstantScalarExpressions.constFound
*/
final class UpdateWebsite
{

/**
* Path to project root (without trailing slash).
*
* @var string
*/
private const PROJECT_ROOT = __DIR__ . '/../..';

/**
* Relative path to target directory off project root (without trailing slash).
*
* @var string
*/
private const TARGET_DIR = 'docs';

/**
* Frontmatter for the website homepage.
*
* @var string
*/
private const README_FRONTMATTER = '---
title: PHPCSUtils
description: "PHPCSUtils: A suite of utility functions for use with PHP_CodeSniffer"
anchor: home
permalink: /
seo:
type: WebSite
publisher:
type: Organisation
---
';

/**
* Frontmatter for the changelog page.
*
* @var string
*/
private const CHANGELOG_FRONTMATTER = '---
title: Changelog
description: "Changelog for the PHPCSUtils suite of utility functions for use with PHP_CodeSniffer"
anchor: changelog
permalink: /changelog
seo:
type: WebSite
publisher:
type: Organisation
---
';

/**
* Resolved path to project root (with trailing slash).
*
* @var string
*/
private $realRoot;

/**
* Resolved path to target directory (with trailing slash).
*
* @var string
*/
private $realTarget;

/**
* Run the transformation.
*
* @return int Exit code.
*/
public function run(): int
{
$exitcode = 0;

try {
$this->setPaths();
$this->transformReadme();
$this->transformChangelog();
} catch (RuntimeException $e) {
echo 'ERROR: ', $e->getMessage(), \PHP_EOL;
$exitcode = 1;
}

return $exitcode;
}

/**
* Validate the paths to use.
*
* @return void
*/
private function setPaths(): void
{
$realRoot = \realpath(self::PROJECT_ROOT) . '/';
if ($realRoot === false) {
throw new RuntimeException(\sprintf('Failed to find the %s directory.', $realRoot));
}

$this->realRoot = $realRoot;

// Check if the target directory exists and if not, create it.
$targetDir = $this->realRoot . self::TARGET_DIR;

if (@\is_dir($targetDir) === false) {
if (@\mkdir($targetDir, 0777, true) === false) {
throw new RuntimeException(\sprintf('Failed to create the %s directory.', $targetDir));
}
}

$realPath = \realpath($targetDir);
if ($realPath === false) {
throw new RuntimeException(\sprintf('Failed to find the %s directory.', $targetDir));
}

$this->realTarget = $realPath . '/';
}

/**
* Apply various transformations to the index page.
*
* - Remove title, badges and index.
* - Replace code samples with properly highlighted versions.
* - Add frontmatter.
*
* @return void
*
* @throws \RuntimeException When any of the expected replacements could not be made.
*/
private function transformReadme(): void
{
$contents = $this->getContents($this->realRoot . 'README.md');

// Remove title, badges and index.
$contents = $this->replace('`^.*## Features`s', '## Features', $contents, 1);

// Remove the section about Non-Composer based integration.
$contents = $this->replace(
'`### Non-Composer based integration[\n\r]+(?:.+[\n\r]+)+?## Frequently Asked Questions`',
'## Frequently Asked Questions',
$contents,
1
);

// Replace installation instructions with properly highlighted version.
$search = '~`{3}bash[\n\r]+composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer'
. ' true[\n\r]+'
. 'composer require phpcsstandards/phpcsutils:"([^\n\r]+)"[\n\r]+`{3}~';
$replace = '<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>'
. 'composer config <span class="s">allow-plugins.dealerdirect/phpcodesniffer-composer-installer</span>'
. ' <span class="mf">true</span>'
. "\n"
. 'composer require <span class="s">{{ site.phpcsutils.packagist }}</span>:"<span class="mf">$1</span>"'
. "\n"
. '</code></pre></div></div>';
$contents = $this->replace($search, $replace, $contents, 1);

// Replace suggested end-user installation instructions with properly highlighted versions.
$search = '~`{3}bash[\r\n]+> composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer'
. ' true[\r\n]+> `{3}~';
$replace = '<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>'
. 'composer config <span class="s">allow-plugins.dealerdirect/phpcodesniffer-composer-installer</span>'
. ' <span class="mf">true</span>'
. "\n"
. '> </code></pre></div></div>';
$contents = $this->replace($search, $replace, $contents, 1);

// Replace suggested end-user upgrade instructions with properly highlighted versions.
$search = '~`{3}bash[\r\n]+> composer update your/cs-package --with-\[all-\]dependencies[\r\n]+> `{3}~';
$replace = '<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>'
. 'composer update <span class="s">your/cs-package</span> <span class="mf">--with-[all-]dependencies</span>'
. "\n"
. '> </code></pre></div></div>';
$contents = $this->replace($search, $replace, $contents, 1);

// Add frontmatter.
$contents = self::README_FRONTMATTER . "\n" . $contents;

$this->putContents($this->realTarget . 'index.md', $contents);
}

/**
* Add frontmatter to the changelog page and remove "Unreleased".
*
* @return void
*/
private function transformChangelog(): void
{
$contents = $this->getContents($this->realRoot . 'CHANGELOG.md');

// Remove the section about Non-Composer based integration.
$contents = $this->replace(
'`## \[Unreleased\][\n\r]+(?:.+[\n\r]+)+?##`',
'##',
$contents,
1
);

// Add frontmatter.
$contents = self::CHANGELOG_FRONTMATTER . "\n" . $contents;

$this->putContents($this->realTarget . 'changelog.md', $contents);
}

/**
* Execute a regex search and replace and verify the replacement was actually made.
*
* @param string $search The pattern to search for.
* @param string $replace The replacement.
* @param string $subject The string to execute the search & replace on.
* @param int $limit Maximum number of replacements to make.
*
* @return string
*
* @throws \RuntimeException When the replacement was not made or not made the required number of times.
*/
private function replace(string $search, string $replace, string $subject, int $limit = 1): string
{
$subject = \preg_replace($search, $replace, $subject, $limit, $count);
if ($count !== $limit) {
throw new RuntimeException(
'Failed to make required replacement.' . \PHP_EOL
. "Search regex: $search" . \PHP_EOL
. "Replacements made: $count"
);
}

return $subject;
}

/**
* Retrieve the contents of a file.
*
* @param string $source Path to the source file.
*
* @return string
*
* @throws \RuntimeException When the contents of the file could not be retrieved.
*/
private function getContents(string $source): string
{
$contents = \file_get_contents($source);
if (!$contents) {
throw new RuntimeException(\sprintf('Failed to read doc file: %s', $source));
}

return $contents;
}

/**
* Write a string to a file.
*
* @param string $target Path to the target file.
* @param string $contents File contents to write.
*
* @return void
*
* @throws \RuntimeException When the target directory could not be created.
* @throws \RuntimeException When the file could not be written to the target directory.
*/
private function putContents(string $target, string $contents): void
{
// Check if the target directory exists and if not, create it.
$targetDir = \dirname($target);

if (@\is_dir($targetDir) === false) {
if (@\mkdir($targetDir, 0777, true) === false) {
throw new RuntimeException(\sprintf('Failed to create the %s directory.', $targetDir));
}
}

// Make sure the file always ends on a new line.
$contents = \rtrim($contents) . "\n";
if (\file_put_contents($target, $contents) === false) {
throw new RuntimeException(\sprintf('Failed to write to target location: %s', $target));
}
}
}
91 changes: 91 additions & 0 deletions .github/GHPages/update-docgen-config.php
@@ -0,0 +1,91 @@
#!/usr/bin/env php
<?php
/**
* PHPCSUtils, utility functions and classes for PHP_CodeSniffer sniff developers.
*
* Update the phpDocumentor configuration file.
*
* {@internal This functionality has a minimum PHP requirement of PHP 7.2.}
*
* @internal
*
* @package PHPCSUtils
* @copyright 2019-2020 PHPCSUtils Contributors
* @license https://opensource.org/licenses/LGPL-3.0 LGPL3
* @link https://github.com/PHPCSStandards/PHPCSUtils
*
* @phpcs:disable PHPCompatibility.FunctionUse.NewFunctionParameters.getenv_local_onlyFound
* @phpcs:disable PHPCompatibility.FunctionUse.NewFunctionParameters.dirname_levelsFound
*/

namespace PHPCSUtils\GHPages;

$phpcsutilsPhpdocVersionUpdater = static function () {
$tagname = \getenv('TAG', true);
if ($tagname === false) {
echo 'ERROR: No TAG environment variable found.', \PHP_EOL;
exit(1);
}

$tagname = \trim($tagname);
if ($tagname === '' || \preg_match('`^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha|beta|rc)[0-9]*)?$`', $tagname) !== 1) {
echo "ERROR: \"$tagname\" is not a valid tag.", \PHP_EOL;
exit(1);
}

$projectRoot = \dirname(__DIR__, 2);
$source = '.phpdoc.xml.dist';
$destination = 'phpdoc.xml';
$count = 0;

if (\file_exists($projectRoot . '/' . $destination)) {
echo "WARNING: Detected pre-existing \"$destination\" file.", \PHP_EOL;
echo "Please make sure that this overload file is in sync with the \"$source\" file.", \PHP_EOL;
echo 'This is your own responsibility!' . \PHP_EOL, \PHP_EOL;

$config = \file_get_contents($projectRoot . '/' . $destination);
if (!$config) {
echo "ERROR: Failed to read phpDocumentor $destination configuration file.", \PHP_EOL;
exit(1);
}

// Replace the previous version nr in the API doc title with the latest version number.
$config = \preg_replace(
'`<title>PHPCSUtils ([\#0-9\.]+)</title>`',
"<title>PHPCSUtils {$tagname}</title>",
$config,
-1,
$count
);
} else {
$config = \file_get_contents($projectRoot . '/' . $source);
if (!$config) {
echo "ERROR: Failed to read phpDocumentor $source configuration template file.", \PHP_EOL;
exit(1);
}

// Replace the "#.#.#" placeholder in the API doc title with the latest version number.
$config = \str_replace(
'<title>PHPCSUtils</title>',
"<title>PHPCSUtils {$tagname}</title>",
$config,
$count
);
}

if ($count !== 1) {
echo "ERROR: Version number text replacement failed. Made $count replacements.", \PHP_EOL;
exit(1);
}

if (\file_put_contents($projectRoot . '/' . $destination, $config) === false) {
echo "ERROR: Failed to write phpDocumentor $destination configuration file.", \PHP_EOL;
exit(1);
} else {
echo "SUCCESFULLY updated/created the $destination file!", \PHP_EOL;
}

exit(0);
};

$phpcsutilsPhpdocVersionUpdater();

0 comments on commit fbbe7c5

Please sign in to comment.