diff --git a/.gitattributes b/.gitattributes index b3d5e0493..62575cea0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ .github/ export-ignore +build/ export-ignore docs/ export-ignore examples/ export-ignore tests/ export-ignore @@ -6,4 +7,5 @@ tests/ export-ignore .gitattributes export-ignore .gitignore export-ignore .phpcs.xml.dist export-ignore +phpdoc.dist.xml export-ignore phpunit.xml.dist export-ignore diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 86dfb3707..be7e9af07 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -49,4 +49,9 @@ jobs: composer-options: --ignore-platform-reqs - name: Lint against parse errors + if: ${{ matrix.php >= '7.2' }} run: composer lint -- --checkstyle | cs2pr + + - name: Lint against parse errors + if: ${{ matrix.php < '7.2' }} + run: composer lint -- --exclude build --checkstyle | cs2pr diff --git a/.github/workflows/update-website.yml b/.github/workflows/update-website.yml new file mode 100644 index 000000000..87096d46d --- /dev/null +++ b/.github/workflows/update-website.yml @@ -0,0 +1,166 @@ +name: Build website + +on: + # Trigger the workflow whenever a new release is created. + release: + types: + - published + # And whenever this workflow or one of the associated scripts is updated. + pull_request: + paths: + - '.github/workflows/update-website.yml' + - 'build/ghpages/**' + # Also allow manually triggering the workflow. + workflow_dispatch: + +jobs: + prepare: + name: "Prepare website update" + # Don't run on forks. + if: github.repository == 'WordPress/Requests' + + runs-on: ubuntu-latest + steps: + # By default use the `stable` branch as the published docs should always + # reflect the latest release. + # For testing changes to the workflow or the scripts, use the PR branch + # to have access to the latest version of the workflow/scripts. + - name: Determine branch to use + id: base_branch + run: | + if [ "${{ github.event_name }}" == "pull_request" ]; then + echo '::set-output name=BRANCH::${{ github.ref }}' + else + echo '::set-output name=BRANCH::stable' + fi + + - name: Checkout code + uses: actions/checkout@v2 + with: + ref: ${{ steps.base_branch.outputs.BRANCH }} + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + ini-values: display_errors=On + coverage: none + + # This will install the phpDocumentor PHAR, not the "normal" dependencies. + - name: Install Composer dependencies + uses: ramsey/composer-install@v1 + with: + composer-options: "--working-dir=build/ghpages/" + + - name: Update the phpDoc configuration + run: php build/ghpages/update-docgen-config.php + + - name: Generate the phpDoc documentation + run: php build/ghpages/vendor/bin/phpdoc + + - name: Transform the markdown docs for use in GH Pages + run: php build/ghpages/update-markdown-docs.php + + # Retention is normally 90 days, but this artifact is only for review + # and use in the next step, so no need to keep it for more than a day. + - name: Upload the artifacts folder + uses: actions/upload-artifact@v2 + if: ${{ success() }} + with: + name: website-updates + path: ./build/ghpages/artifacts + if-no-files-found: error + retention-days: 1 + + createpr: + name: "Create website update PR" + needs: prepare + # Don't run on forks. + if: github.repository == 'WordPress/Requests' + + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + ref: gh-pages + + - name: Download the prepared artifacts + uses: actions/download-artifact@v2 + with: + name: website-updates + path: artifacts + + # Different version of phpDocumentor may add/remove files for CSS/JS etc. + # Similarly, a new Requests major may remove classes. + # So we always need to make sure that the old version of the API docs are + # cleared out completely. + - name: Clear out the API directory + run: rm -vrf ./api/* + + - name: Move the updated API doc files + run: mv -fv artifacts/api/* ./api/ + + # The commit should contain all changes in the API directory, both tracked and untracked! + - name: Commit the API docs separately + run: | + git config user.name 'GitHub Action' + git config user.email '${{ github.actor }}@users.noreply.github.com' + git add -A ./api/ + git commit --allow-empty --message="GH Pages: update API docs for Requests ${{ github.ref }}" + + # Similar to the API docs, files could be removed from the prose docs, so + # make sure that the directory is cleared out completely beforehand. + - name: Clear out the docs directory + run: rm -vf ./docs/* + + - name: Move the other updated files + run: | + mv -fv artifacts/docs/* ./docs + mv -fv artifacts/_includes/* ./_includes + mv -fv artifacts/index.md ./index.md + + - name: Verify artifacts directory is now empty + run: ls -alR artifacts + + # The directory is also gitignored, but just to be sure. + - name: Remove the artifacts directory + run: rmdir --ignore-fail-on-non-empty --verbose ./artifacts + + # PRs based on the "pull request" event trigger will contain changes from the + # current `develop` branch, so should not be published as the website should + # always be based on the latest release. + - name: Determine PR title prefix, body and more + id: get_pr_info + run: | + if [ "${{ github.event_name }}" == "pull_request" ]; then + echo '::set-output name=PR_TITLE_PREFIX::[TEST | DO NOT MERGE] ' + echo '::set-output name=PR_BODY::Test run for the website update after changes to the automated scripts.' + echo '::set-output name=DRAFT::true' + else + echo '::set-output name=PR_TITLE_PREFIX::' + echo '::set-output name=PR_BODY::Website update after the release of Requests ${{ github.ref }}.' + echo '::set-output name=DRAFT::false' + fi + + - name: Show status + run: git status -vv --untracked=all + + - name: Create pull request + uses: peter-evans/create-pull-request@v3 + with: + base: gh-pages + branch: feature/auto-ghpages-update-${{ github.ref }} + delete-branch: true + commit-message: "GH Pages: update other docs for Requests ${{ github.ref }}" + title: "${{ steps.get_pr_info.outputs.PR_TITLE_PREFIX }}:books: Update GHPages website" + body: | + ${{ steps.get_pr_info.outputs.PR_BODY }} + + This PR is auto-generated by [create-pull-request](https://github.com/peter-evans/create-pull-request). + labels: | + "Type: documentation" + reviewer: | + jrfnl + schlessera + draft: ${{ steps.get_pr_info.outputs.DRAFT }} diff --git a/.gitignore b/.gitignore index 535811fa5..13a6c9956 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ tests/coverage/* # Ignore composer related files /composer.lock /vendor +build/ghpages/composer.lock +build/ghpages/vendor # Ignore local overrides of the PHPCS config file. .phpcs.xml @@ -11,3 +13,8 @@ phpcs.xml # Ignore PHPUnit results cache file. .phpunit.result.cache + +# Ignore temporary files for ghpages builds. +phpdoc.xml +build/ghpages/.phpdoc +build/ghpages/artifacts diff --git a/build/ghpages/README.md b/build/ghpages/README.md new file mode 100644 index 000000000..2637b2eb5 --- /dev/null +++ b/build/ghpages/README.md @@ -0,0 +1,27 @@ +Scripts to update the GitHub Pages website +====================================== + +The scripts in this directory are only for internal use to update the GitHub Pages website associated with this project whenever a new version of the Requests library is released. + +They are used in the [`update-website.yml`](https://github.com/WordPress/Requests/blob/develop/.github/workflows/update-website.yml) GitHub Actions workflow. + +To run a test build of the GitHub Pages site locally, execute the following steps: + +Preparation in this repo: +* Pre-requisite: use PHP 7.2 or higher. +* From within this subdirectory, run `composer update -W`. +* Delete the `build/ghpages/artifacts` subdirectory completely. + +Preparation of the GitHub Pages branch: +* Clone this repo a second time outside of the root of this clone and check out the `gh-pages` branch. +* Create a new branch (git). +* Delete the `api` directory completely. +* Delete the `docs` directory completely. + +Switch to the project root directory in this clone and: +* Run `php build/ghpages/update-docgen-config.php` to retrieve the latest tag number from the GH API and create/update the `phpdoc.xml` config. +* If this was the first time you ran the above script, you now need to edit the `phpdoc.xml` file and update the path in the `` - `` config to point to the `root/api` directory of the "gh-pages" clone of the repo. +* Run `php build/ghpages/vendor/bin/phpdoc` to generate the API docs. +* Run `php build/ghpages/update-markdown-docs.php --target=path/to/gh-pages/root` to generate versions of the markdown docs suitable for use in GH Pages. + +You can then use git diff to verify the GH Pages site was updated correctly. diff --git a/build/ghpages/UpdateMarkdown.php b/build/ghpages/UpdateMarkdown.php new file mode 100644 index 000000000..505ca5380 --- /dev/null +++ b/build/ghpages/UpdateMarkdown.php @@ -0,0 +1,366 @@ +process_cli_args(); + } + + /** + * Process received CLI arguments. + * + * Only one argument is supported: "--target" to set the target path. + * + * @return void + */ + private function process_cli_args(): void { + $args = $_SERVER['argv']; + + // Remove the call to the script itself. + \array_shift($args); + + if (empty($args)) { + // No options set. + return; + } + + foreach ($args as $arg) { + preg_match('`--target=([\'"])?([^\'"]+)\1?`', $arg, $matches); + if (empty($matches) || isset($matches[2]) === false) { + // Not a valid CLI argument, only "target" is supported. + continue; + } + + $cwd = getcwd(); + $target = $matches[2]; + + /* + * Attempt some minimal path resolving. + * Note: the target directory may not exist, so just guestimating for most common cases here. + */ + if (strpos($target, '..') !== 0 && $target[0] === '.') { + $this->target = $cwd . substr($target, 1); + break; + } + + if (strpos($target, '..') === 0) { + while (strpos($target, '../') === 0 || strpos($target, '..\\') === 0) { + $cwd = dirname($cwd); + $target = substr($target, 3); + } + + $this->target = $cwd . '/' . $target; + break; + } + + /* + * In all other cases, presume it is a valid absolute path. + * The `put_contents()` method will throw appropriate errors if it's not. + */ + $this->target = $target; + } + } + + /** + * Run the transformation. + * + * @return int Exit code. + */ + public function run(): int { + $exitcode = 0; + + try { + $this->update_homepage(); + $this->update_docs(); + } catch (RuntimeException $e) { + echo 'ERROR: ', $e->getMessage(), PHP_EOL; + $exitcode = 1; + } + + return $exitcode; + } + + /** + * Transform the repo README to the website homepage. + * + * @return void + */ + private function update_homepage(): void { + // Read the file. + $contents = $this->get_contents(dirname(__DIR__, 2) . '/README.md'); + + // Remove badges. + $contents = preg_replace( + '`([=]+[\n\r]+)(?:\[!\[[^\]]+\]\([^\)]+\)\]\([^\)]+\)[\n\r]+)+`', + '$1', + $contents + ); + + // Replace repo refs with GH Pages automatic replacement syntax. + $contents = preg_replace( + '`\brmccue/requests\b`', + '{{ site.requests.packagist }}', + $contents + ); + + // Replace version nr refs with GH Pages GH API automatic replacement syntax. + $contents = preg_replace( + '`"(?:>=1\.0|\^2\.0)"`', + '"^{{ site.github.latest_release.tag_name | replace_first: \'v\', \'\' }}"', + $contents + ); + + // Replace clone url refs with GH Pages GH API automatic replacement syntax. + $contents = str_replace( + '$ git clone git://github.com/WordPress/Requests.git', + '$ git clone {{ site.github.clone_url }}', + $contents + ); + + // Replace prose-based documentation link. + $contents = preg_replace( + '`\[prose-based documentation\]:[^\r\n]+`', + '[prose-based documentation]: {{ \'/docs/\' | prepend: site.baseurl }}', + $contents + ); + + // Update links. + $contents = $this->update_links($contents); + + // Add frontmatter. + $contents = $this->home_frontmatter . "\n" . $contents; + + // Write the file. + $target = $this->target . '/index.md'; + $this->put_contents($target, $contents, 'doc index file'); + } + + /** + * Transform all docs in the `docs` directory for use in GH Pages. + * + * @return void + */ + private function update_docs(): void { + // Create the file list. + $sep = \DIRECTORY_SEPARATOR; + $pattern = dirname(__DIR__, 2) . $sep . 'docs' . $sep . '*.md'; + $file_list = glob($pattern, GLOB_NOESCAPE); + + if (empty($file_list)) { + throw new RuntimeException('Failed to find doc files.'); + } + + $base_target = $this->target . '/docs/'; + + foreach ($file_list as $file) { + $basename = basename($file); + + if ($basename === 'README.md') { + $this->update_docs_navigation($file); + } else { + $target = $base_target . $basename; + $this->update_doc($file, $target); + } + } + } + + /** + * Transform a "normal" docs markdown document for use in GHPages. + * + * @param string $source Path to the source file. + * @param string $target Path to the output file. + * + * @return void + */ + private function update_doc(string $source, string $target): void { + // Read the file. + $contents = $this->get_contents($source); + + // Grab the title. + $title = $this->get_title_from_contents($contents); + if (!$title) { + throw new RuntimeException(sprintf('Failed to find page title in doc file: %s', $source)); + } + + // Update links. + $contents = $this->update_links($contents); + + // Add the frontmatter. + $contents = sprintf($this->docs_frontmatter, $title) . "\n" . $contents; + + // Write the file. + $this->put_contents($target, $contents); + } + + /** + * Transform the index page of the docs directory into two separate files for use in GHPages. + * + * @param string $source Path to the source file. + * + * @return void + */ + private function update_docs_navigation(string $source): void { + // Read the file. + $contents = $this->get_contents($source); + + // Update links. + $contents = $this->update_links($contents); + + // Split the file. + $parts = explode('', $contents); + + if (count($parts) !== 2) { + throw new RuntimeException(sprintf('Failed to split the docs index file: %s', $source)); + } + + /* + * Create the docs index file. + */ + $docs_index = trim($parts[0]); + + // Grab the title. + $title = $this->get_title_from_contents($contents); + + // Add the frontmatter. + $docs_index = sprintf($this->docs_frontmatter, $title) . "\n" . $docs_index; + + // Add the navigation include. + $docs_index .= "\n\n" . '{% include navigation.md %}'; + + // Write the file. + $target = $this->target . '/docs/index.md'; + $this->put_contents($target, $docs_index, 'doc index file'); + + /* + * Create the docs navigation file. + */ + $navigation = trim($parts[1]); + + // Write the file. + $target = $this->target . '/_includes/navigation.md'; + $this->put_contents($target, $navigation, 'navigation file'); + } + + /** + * Retrieve the contents of a file. + * + * @param string $source Path to the source file. + * + * @return string + */ + private function get_contents(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. + * @param string $type Type of file to use in error message. + * + * @return string + */ + private function put_contents(string $target, string $contents, string $type = 'doc file'): void { + // Check if the target directory exists and if not, create it. + $target_dir = dirname($target); + + // phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged -- Silencing warnings when function fails. + if (@is_dir($target_dir) === false) { + if (@mkdir($target_dir, 0777, true) === false) { + throw new RuntimeException(sprintf('Failed to create the %s directory.', $target_dir)); + } + } + // phpcs:enable WordPress + + // 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 %s to target location: %s', $type, $target)); + } + } + + /** + * Retrieve the page title from the content of a markdown file. + * + * @param string $contents Contents of a markdown file. + * + * @return string + */ + private function get_title_from_contents(string $contents): string { + return trim(substr($contents, 0, (strpos($contents, '===') - 1))); + } + + /** + * Update links in the contents of a markdown file to make them usable in the context of GHPages. + * + * @param string $contents Markdown document contents. + * + * @return string + */ + private function update_links(string $contents): string { + $contents = str_ireplace('README.md', 'index.md', $contents); + $contents = preg_replace('`\b(\S+)\.md\b`i', '$1.html', $contents); + + return $contents; + } +} diff --git a/build/ghpages/composer.json b/build/ghpages/composer.json new file mode 100644 index 000000000..4c455fa02 --- /dev/null +++ b/build/ghpages/composer.json @@ -0,0 +1,8 @@ +{ + "require" : { + "php" : ">=7.2" + }, + "require-dev": { + "phpdocumentor/shim": "^3" + } +} diff --git a/build/ghpages/update-docgen-config.php b/build/ghpages/update-docgen-config.php new file mode 100644 index 000000000..ed56b9c0a --- /dev/null +++ b/build/ghpages/update-docgen-config.php @@ -0,0 +1,91 @@ +#!/usr/bin/env php + 'application/vnd.github.v3+json', + ) + ); + + if (!($response instanceof Response) || $response->success !== true || $response->status_code !== 200) { + echo 'ERROR: GH API request to retrieve the version number of the last release failed.', PHP_EOL; + exit(1); + } + + $decoded = json_decode($response->body, true); + if (!isset($decoded['tag_name'])) { + echo 'ERROR: GH API request to retrieve the version number of the last release failed to retrieve a version number.', PHP_EOL; + exit(1); + } + + $tagname = ltrim($decoded['tag_name'], 'v'); + + if (file_exists($project_root . '/phpdoc.xml')) { + echo 'WARNING: Detected pre-existing "phpdoc.xml" file.', PHP_EOL; + echo 'Please make sure that this overload file is in sync with the "phpdoc.dist.xml" file.', PHP_EOL; + echo 'This is your own responsibility!' . PHP_EOL, PHP_EOL; + + $config = file_get_contents($project_root . '/phpdoc.xml'); + if (!$config) { + echo 'ERROR: Failed to read phpDocumentor configuration template file.', PHP_EOL; + exit(1); + } + + // Replace the previous version nr in the API doc title with the latest version number. + $config = preg_replace( + '`Requests ([\#0-9\.]+) API`', + "Requests {$tagname} API", + $config + ); + } else { + $config = file_get_contents($project_root . '/phpdoc.dist.xml'); + if (!$config) { + echo 'ERROR: Failed to read phpDocumentor configuration template file.', PHP_EOL; + exit(1); + } + + // Replace the "#.#.#" placeholder in the API doc title with the latest version number. + $config = str_replace( + 'Requests #.#.# API', + "Requests {$tagname} API", + $config + ); + } + + if (file_put_contents($project_root . '/phpdoc.xml', $config) === false) { + echo 'ERROR: Failed to write phpDocumentor configuration file.', PHP_EOL; + exit(1); + } else { + echo 'SUCCESFULLY updated/created the phpdoc.xml file!', PHP_EOL; + } + + exit(0); +}; + +$requests_phpdoc_version_updater(); diff --git a/build/ghpages/update-markdown-docs.php b/build/ghpages/update-markdown-docs.php new file mode 100644 index 000000000..86f112bff --- /dev/null +++ b/build/ghpages/update-markdown-docs.php @@ -0,0 +1,21 @@ +#!/usr/bin/env php +run(); + +exit($requests_website_update_success); diff --git a/docs/README.md b/docs/README.md index 65975c52a..d61027fe4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,8 @@ here are prose; you might also want to check out the [API documentation][]. [API documentation]: https://requests.ryanmccue.info/api/ + + * Introduction * [Goals][goals] * [Why should I use Requests instead of X?][why-requests] diff --git a/phpdoc.dist.xml b/phpdoc.dist.xml new file mode 100644 index 000000000..ac7b35a51 --- /dev/null +++ b/phpdoc.dist.xml @@ -0,0 +1,33 @@ + + + + Requests #.#.# API + + + build/ghpages/artifacts/api/ + build/ghpages/.phpdoc/ + + + + + + src + + public + protected + + codeCoverageIgnore + phpcs + todo + + + + +