diff --git a/.github/workflows/vortex-release.yml b/.github/workflows/vortex-release.yml index 44438e445..bb1b73aba 100644 --- a/.github/workflows/vortex-release.yml +++ b/.github/workflows/vortex-release.yml @@ -145,7 +145,6 @@ jobs: env: CURRENT_MAJOR: ${{ vars.VORTEX_CURRENT_MAJOR || '1' }} - OTHER_MAJOR: ${{ (vars.VORTEX_CURRENT_MAJOR || '1') == '1' && '2' || '1' }} steps: - name: Checkout code @@ -213,23 +212,12 @@ jobs: run: yarn install --frozen-lockfile working-directory: .vortex/docs - # Assemble the multi-version site for production: snapshot this ref's docs - # as the current major (served at the bare '/docs') and pull the other - # major's '{N}.x' branch docs in at '/docs/v{other}'. All driven by - # 'VORTEX_CURRENT_MAJOR'. Mirrors the same step in 'vortex-test-docs.yml' - # (development / Netlify). - - name: Assemble versioned docs - run: | - yarn docusaurus docs:version "${CURRENT_MAJOR}.x" - git -C "${{ github.workspace }}" fetch origin "${OTHER_MAJOR}.x" --depth=1 || { echo "Failed to fetch the ${OTHER_MAJOR}.x branch."; exit 1; } - git -C "${{ github.workspace }}" rev-parse --verify "origin/${OTHER_MAJOR}.x" || { echo "The ${OTHER_MAJOR}.x branch does not exist."; exit 1; } - rm -rf content - git -C "${{ github.workspace }}" checkout "origin/${OTHER_MAJOR}.x" -- .vortex/docs/content || { echo "Failed to check out content from ${OTHER_MAJOR}.x."; exit 1; } - working-directory: .vortex/docs - - - name: Build documentation site - run: yarn run build - working-directory: .vortex/docs + # Production (GitHub Pages) serves the same combined site that + # 'vortex-test-docs.yml' builds for development deploys and previews. The + # binaries copied above are picked up from 'docs/static'. + - name: Build the combined documentation site + run: ./docs/.utils/assemble-combined-docs.sh + working-directory: .vortex env: VORTEX_CURRENT_MAJOR: ${{ env.CURRENT_MAJOR }} @@ -250,7 +238,7 @@ jobs: - name: Upload documentation site uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: - path: .vortex/docs/build + path: .vortex/docs_combined/build - name: Deploy to GitHub Pages uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 diff --git a/.github/workflows/vortex-test-docs.yml b/.github/workflows/vortex-test-docs.yml index 16af91e87..5f1bb771f 100644 --- a/.github/workflows/vortex-test-docs.yml +++ b/.github/workflows/vortex-test-docs.yml @@ -25,6 +25,10 @@ jobs: env: CURRENT_MAJOR: ${{ vars.VORTEX_CURRENT_MAJOR || '1' }} OTHER_MAJOR: ${{ (vars.VORTEX_CURRENT_MAJOR || '1') == '1' && '2' || '1' }} + # The major the triggering commit ships. Each major builds its own binary + # from its own workflow, so the workflow name identifies the major for + # pushes, branches and fork pull requests alike. + DOCS_MAJOR: ${{ github.event.workflow_run.name == 'Vortex - Test CLI' && '2' || '1' }} BINARY_ARTIFACT: ${{ github.event.workflow_run.name == 'Vortex - Test CLI' && 'vortex-cli' || 'vortex-installer' }} BINARY_FILE: ${{ github.event.workflow_run.name == 'Vortex - Test CLI' && 'vortex.phar' || 'installer.phar' }} @@ -99,22 +103,6 @@ jobs: run: yarn run spellcheck working-directory: '${{ github.workspace }}/.vortex/docs' - # On the main (development) deploy, assemble the multi-version site: - # snapshot main's docs as the current major (served at the bare '/docs') - # and pull the other major's '{N}.x' branch docs in at '/docs/v{other}'. - # Driven by 'VORTEX_CURRENT_MAJOR'. Skipped on PR/branch builds, which stay - # single-version previews. Mirrors the same step in 'vortex-release.yml' - # (production / GitHub Pages). - - name: Assemble versioned docs - if: github.event.workflow_run.head_branch == 'main' - run: | - yarn docusaurus docs:version "${CURRENT_MAJOR}.x" - git -C "${{ github.workspace }}" fetch origin "${OTHER_MAJOR}.x" --depth=1 || { echo "Failed to fetch the ${OTHER_MAJOR}.x branch."; exit 1; } - git -C "${{ github.workspace }}" rev-parse --verify "origin/${OTHER_MAJOR}.x" || { echo "The ${OTHER_MAJOR}.x branch does not exist."; exit 1; } - rm -rf content - git -C "${{ github.workspace }}" checkout "origin/${OTHER_MAJOR}.x" -- .vortex/docs/content || { echo "Failed to check out content from ${OTHER_MAJOR}.x."; exit 1; } - working-directory: '${{ github.workspace }}/.vortex/docs' - # On the main deploy, publish both majors to match the multi-version # docs: '/v1/install' and '/v2/install' are the stable per-major pins and # the bare '/install' is a copy of the current major's pin (the @@ -146,11 +134,15 @@ jobs: cp "vortex-v${OTHER_MAJOR}/.vortex/cli/.build/vortex.phar" ".vortex/docs/static/v${OTHER_MAJOR}/install" cp ".vortex/docs/static/v${CURRENT_MAJOR}/install" .vortex/docs/static/install - - name: Build documentation site - run: yarn run build - working-directory: '${{ github.workspace }}/.vortex/docs' + # Building the combined site is what checks links across both majors, so + # every branch builds it. It runs after the binaries are staged, which the + # assembly copies across with the rest of 'docs/'. + - name: Build the combined documentation site + run: ./docs/.utils/assemble-combined-docs.sh + working-directory: '${{ github.workspace }}/.vortex' env: VORTEX_CURRENT_MAJOR: ${{ env.CURRENT_MAJOR }} + VORTEX_DOCS_MAJOR: ${{ env.DOCS_MAJOR }} # This workflow runs via 'workflow_run', where 'github.ref' is always the # default branch, so the action's branch-based production detection cannot @@ -163,7 +155,7 @@ jobs: id: netlify uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0 with: - publish-dir: '.vortex/docs/build' + publish-dir: '.vortex/docs_combined/build' production-deploy: ${{ github.event.workflow_run.head_branch == 'main' }} deploy-message: "Deploy from GitHub Actions" enable-pull-request-comment: false diff --git a/.vortex/.ahoy.yml b/.vortex/.ahoy.yml index 366989a04..28a8354d3 100644 --- a/.vortex/.ahoy.yml +++ b/.vortex/.ahoy.yml @@ -35,6 +35,18 @@ commands: [ ! -d ./docs/node_modules ] && yarn --cwd=docs install --frozen-lockfile yarn --cwd=docs run build + docs-combined: + usage: Build the combined site serving both majors into the disposable 'docs_combined' directory. + cmd: ./docs/.utils/assemble-combined-docs.sh + + docs-combined-serve: + usage: Serve the built combined site on port 4000. Pass a port as the first argument to override. + cmd: | + [ -d ./docs_combined/build ] || { echo "No combined site found. Run 'ahoy docs-combined' first."; exit 1; } + port="${1:-4000}" + echo "Serving the combined documentation site at http://localhost:${port}" + php -S "localhost:${port}" -t ./docs_combined/build ./docs/.utils/serve-router.php + build-installer: name: Build documentation. cmd: | @@ -138,7 +150,7 @@ commands: cmd: yarn --cwd=docs run update-variables update-videos: - usage: Update documentation videos. Pass names to record a subset (installer, build, provision, lint, test, test-bdd); default is all six. + usage: Update documentation videos. Pass names to record a subset (installer, build, provision, lint, test, test-bdd, info, doctor, doctor-info); default is all of them. Pass --verify to re-render the committed artefacts and compare instead of recording. cmd: php docs/.utils/update-videos.php "$@" entrypoint: diff --git a/.vortex/.gitignore b/.vortex/.gitignore new file mode 100644 index 000000000..5949aeabb --- /dev/null +++ b/.vortex/.gitignore @@ -0,0 +1,4 @@ +# The combined documentation site built by `ahoy docs-combined`. It is a +# generated copy of `docs/` carrying both majors, rebuilt from scratch on every +# run and safe to delete at any time. +/docs_combined diff --git a/.vortex/docs/.gitignore b/.vortex/docs/.gitignore index b773ebf90..de5b217ff 100644 --- a/.vortex/docs/.gitignore +++ b/.vortex/docs/.gitignore @@ -10,9 +10,9 @@ .docusaurus-dev .cache-loader -# Assembled docs versions - created by the CI publish step (or a local -# `docusaurus docs:version` to preview the multi-version site) and never -# committed; the build auto-detects `versioned_docs/` to switch to multi-version. +# Version snapshots. Only the disposable `docs_combined/` site carries them, so +# these never appear here in normal use; the pattern stays as a guard against a +# stray local `docusaurus docs:version` run being committed. /versioned_docs /versioned_sidebars /versions.json diff --git a/.vortex/docs/.utils/assemble-combined-docs.sh b/.vortex/docs/.utils/assemble-combined-docs.sh new file mode 100755 index 000000000..95776f895 --- /dev/null +++ b/.vortex/docs/.utils/assemble-combined-docs.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +## +# Assemble the combined documentation site, serving both majors from one build. +# +# Produces a complete, self-contained Docusaurus site in 'docs_combined/'. The +# current major is served at '/docs' with its assets at '/img'; the other major +# is served at '/docs/v{other}' with its assets at '/v{other}/img'. +# +# This checkout supplies the documentation for the major it ships +# ('VORTEX_DOCS_MAJOR'); the other major is read from its own branch. So a +# branch off 'main' is built as the current major and '{other}.x' is fetched, +# while a branch off '{other}.x' is built as the other major and 'main' is +# fetched. Everything the assembly writes stays inside 'docs_combined/', which +# is disposable - 'docs/' is only ever read. +# +# @usage +# cd .vortex && ./docs/.utils/assemble-combined-docs.sh + +set -eu +set -o pipefail +[ "${VORTEX_DEBUG-}" = "1" ] && set -x + +# The major served as the site's default version at the bare '/docs'. +VORTEX_CURRENT_MAJOR="${VORTEX_CURRENT_MAJOR:-1}" + +# The major this checkout ships. Defaults to the current major, which is the +# major that lives on the default branch. +VORTEX_DOCS_MAJOR="${VORTEX_DOCS_MAJOR:-${VORTEX_CURRENT_MAJOR}}" + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../" && pwd)" +DOCS_DIR="${ROOT_DIR}/.vortex/docs" +COMBINED_DIR="${ROOT_DIR}/.vortex/docs_combined" + +sed_opts=(-i) && [ "$(uname)" = "Darwin" ] && sed_opts=(-i '') + +for major in "${VORTEX_CURRENT_MAJOR}" "${VORTEX_DOCS_MAJOR}"; do + case "${major}" in + 1 | 2) ;; + *) + echo "ERROR: Invalid major '${major}'. Expected 1 or 2." >&2 + exit 1 + ;; + esac +done + +case "${VORTEX_CURRENT_MAJOR}" in + 1) other_major=2 ;; + 2) other_major=1 ;; +esac + +# The current major lives on the default branch; every other major lives on its +# own '{N}.x' branch. Whichever side this checkout does not supply is fetched. +if [ "${VORTEX_DOCS_MAJOR}" = "${VORTEX_CURRENT_MAJOR}" ]; then + default_ref="" + other_ref="origin/${other_major}.x" +else + default_ref="origin/main" + other_ref="" +fi + +fetch_ref="${default_ref}${other_ref}" +fetch_branch="${fetch_ref#origin/}" + +git -C "${ROOT_DIR}" fetch origin "${fetch_branch}" --depth=1 || { + echo "ERROR: Failed to fetch the ${fetch_branch} branch." >&2 + exit 1 +} + +git -C "${ROOT_DIR}" rev-parse --verify "${fetch_ref}" >/dev/null || { + echo "ERROR: The ${fetch_branch} branch does not exist." >&2 + exit 1 +} + +## +# Replace a directory with a major's documentation tree. +# +# $1 - directory to populate. +# $2 - subdirectory of the documentation to read ('content' or 'static'). +# $3 - git ref to read from, or empty to read this checkout. +## +populate() { + local dest="${1}" + local tree="${2}" + local ref="${3}" + + rm -rf "${dest}" + mkdir -p "${dest}" + + if [ -z "${ref}" ]; then + cp -R "${DOCS_DIR}/${tree}/." "${dest}/" + else + git -C "${ROOT_DIR}" archive "${ref}:.vortex/docs/${tree}" | tar -x -C "${dest}" + fi + + [ -n "$(ls -A "${dest}")" ] || { + echo "ERROR: No '${tree}' found in ${ref:-this checkout}." >&2 + exit 1 + } +} + +rm -rf "${COMBINED_DIR}" +mkdir -p "${COMBINED_DIR}" + +# The combined site is a copy of this one, so it builds with the same config, +# components and sidebars. Generated and installed directories are excluded and +# rebuilt inside it, so it depends on nothing outside itself. +rsync -a \ + --exclude '/node_modules' \ + --exclude '/build' \ + --exclude '/.docusaurus' \ + --exclude '/.docusaurus-dev' \ + --exclude '/.logs' \ + "${DOCS_DIR}/" "${COMBINED_DIR}/" + +yarn --cwd="${COMBINED_DIR}" install --frozen-lockfile + +# Snapshot the current major, whose assets stay at the bare static root. +populate "${COMBINED_DIR}/content" content "${default_ref}" +populate "${COMBINED_DIR}/static" static "${default_ref}" + +# 'VORTEX_DOCS_COMBINED' stays unset here: the snapshot the combined config +# expects does not exist until this command creates it. +yarn --cwd="${COMBINED_DIR}" docusaurus docs:version "${VORTEX_CURRENT_MAJOR}.x" + +# Hand 'content/' over to the other major. Both majors record their own demo +# videos and diagrams under the same 'static/img' names, so the other major's +# assets get their own '/v{other}' prefix instead of losing to the current +# major's copies. +other_static_dir="${COMBINED_DIR}/static/v${other_major}" + +# The other major's binary is built into 'static/v{other}' before the assembly +# runs and is not tracked on any branch, so replacing that directory with the +# branch's own static tree would discard it. +staged_other_install="${DOCS_DIR}/static/v${other_major}/install" +[ -f "${staged_other_install}" ] || staged_other_install="" + +populate "${COMBINED_DIR}/content" content "${other_ref}" +populate "${other_static_dir}" static "${other_ref}" + +[ -z "${staged_other_install}" ] || cp "${staged_other_install}" "${other_static_dir}/install" + +# Every branch authors its docs against the bare '/docs' mount, so an absolute +# link lands on the current major once the content is served at '/docs/v{other}'. +# Re-point those links at the major they were written for. A link that already +# names a version is left as authored, so the alternation skips '/v' followed by +# a digit. +find "${COMBINED_DIR}/content" -type f \( -name '*.md' -o -name '*.mdx' \) -exec \ + sed -E "${sed_opts[@]}" "s%\]\(/docs([)#?]|/[^v]|/v[^0-9])%](/docs/v${other_major}\1%g" {} + + +# Asset references are written against the bare static root for the same reason. +# Each directory the other major ships is remapped, so a new asset directory on +# that branch is carried over without editing this script. Markdown targets and +# JSX attribute values are the two forms an asset path appears in. +for asset_dir in "${other_static_dir}"/*/; do + [ -d "${asset_dir}" ] || continue + + asset_name="$(basename "${asset_dir}")" + + find "${COMBINED_DIR}/content" -type f \( -name '*.md' -o -name '*.mdx' \) -exec \ + sed -E "${sed_opts[@]}" "s%([\"'(])/${asset_name}/%\1/v${other_major}/${asset_name}/%g" {} + +done + +VORTEX_DOCS_COMBINED=1 VORTEX_CURRENT_MAJOR="${VORTEX_CURRENT_MAJOR}" yarn --cwd="${COMBINED_DIR}" run build + +echo "Combined documentation site built at ${COMBINED_DIR}/build" diff --git a/.vortex/docs/.utils/serve-router.php b/.vortex/docs/.utils/serve-router.php new file mode 100644 index 000000000..288b1f5a8 --- /dev/null +++ b/.vortex/docs/.utils/serve-router.php @@ -0,0 +1,55 @@ + serve-router.php + */ + +declare(strict_types=1); + +$doc_root = $_SERVER['DOCUMENT_ROOT']; +$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); +$path = is_string($path) ? rawurldecode($path) : '/'; + +// Reject traversal outside the build directory before touching the filesystem. +if (str_contains($path, '..')) { + http_response_code(400); + + return TRUE; +} + +$target = $doc_root . $path; + +// Serve an existing file as-is and let the server set the content type. +if (is_file($target)) { + return FALSE; +} + +$index = rtrim($target, '/') . '/index.html'; + +if (is_file($index)) { + header('Content-Type: text/html; charset=UTF-8'); + readfile($index); + + return TRUE; +} + +// Fall back to the site's own 404 page so navigation errors look like the site. +$not_found = $doc_root . '/404.html'; + +http_response_code(404); + +if (is_file($not_found)) { + header('Content-Type: text/html; charset=UTF-8'); + readfile($not_found); +} + +return TRUE; diff --git a/.vortex/docs/content/contributing/maintenance/documentation.mdx b/.vortex/docs/content/contributing/maintenance/documentation.mdx index b746e5ebb..0fa960559 100644 --- a/.vortex/docs/content/contributing/maintenance/documentation.mdx +++ b/.vortex/docs/content/contributing/maintenance/documentation.mdx @@ -75,6 +75,42 @@ The 6 terminal demo videos embedded in the docs are regenerated with `ahoy update-videos [names]` from `.vortex/` - see [Installer > Installer video](installer.mdx#installer-video) for the pipeline. +### Combined site + +The published site serves two majors at once. The branch that ships the current +major - selected by the `VORTEX_CURRENT_MAJOR` repository variable, default `1` - +is snapshotted as the default version at `/docs`, and the other major's `{N}.x` +branch content is served at `/docs/v{N}`. + +Each branch writes its content against the bare `/docs` mount, so an absolute +link such as `/docs/tools/behat` points at the current major. When that content +is assembled under `/docs/v{N}`, those links are re-pointed at the major they +were written for, and a link that already names a version is left alone. Write +links the way they read on their own branch. + +Static assets work the same way. Both majors record their own demo videos and +diagrams under the same `static/img` names, so the other major's `static/` is +served from `/v{N}` and its asset references are re-pointed to match. Each +version therefore shows its own screenshots and recordings. Reference assets the +way they read on their own branch, from the bare `/img`. + +To build and browse the combined site locally: + +```shell +cd .vortex + +# Build both majors into 'docs_combined'. Fails on broken internal links. +ahoy docs-combined + +# Serve the result on port 4000. Pass a port to override. +ahoy docs-combined-serve +``` + +Everything the build writes stays inside `docs_combined`, a git-ignored copy of +`docs` that is rebuilt from scratch on every run and safe to delete at any time. +The tracked `docs` directory is only ever read, so `ahoy docs` and +`ahoy build-docs` keep working on this branch's documentation alone. + ### Publishing Automated continuous integration builds publish this documentation: diff --git a/.vortex/docs/docusaurus.config.js b/.vortex/docs/docusaurus.config.js index e79364be4..c5339d91f 100644 --- a/.vortex/docs/docusaurus.config.js +++ b/.vortex/docs/docusaurus.config.js @@ -4,29 +4,9 @@ // There are various equivalent ways to declare your Docusaurus config. // @see https://docusaurus.io/docs/api/docusaurus-config -import fs from 'node:fs'; - import {themes as prismThemes} from 'prism-react-renderer'; -// Multi-version mode turns on automatically when a 'versioned_docs/' snapshot -// is present: 'versioned_docs/version-1.x' is v1 (the default, served at the -// bare '/docs') and the current 'content/' is v2 (served at '/docs/v2'). With -// no snapshot - local development, per-branch preview builds, and the -// 'docusaurus docs:version' run that creates the snapshot - the site builds -// 'content/' as a single unversioned set, so the config never references a -// version that does not exist yet. The publish jobs assemble the snapshot in -// CI; it is never committed to a branch. -const versioned = fs.existsSync('versioned_docs'); - -// The current major (the 'VORTEX_CURRENT_MAJOR' repository variable, default 1) -// drives the whole site: its docs are a snapshot under 'versioned_docs/' served -// as the default at the bare '/docs', and the live 'content/' (pulled from the -// other major's '{N}.x' branch in CI) is served at '/docs/v{other}'. Bumping -// that one variable promotes a new major - nothing else changes here. -const currentMajor = process.env.VORTEX_CURRENT_MAJOR || '1'; -const otherMajor = currentMajor === '1' ? '2' : '1'; -const currentDocsVersion = `${currentMajor}.x`; -const otherIsNewer = Number(otherMajor) > Number(currentMajor); +import {docsVersions, versionNavbarItems, versionRedirects} from './versions.js'; /** @type {import('@docusaurus/types').Config} */ const config = { @@ -84,23 +64,7 @@ const config = { // Please change this to your repo. // Remove this to remove the "edit this page" links. editUrl: 'https://github.com/drevops/vortex/tree/main/.vortex/docs/', - // In versioned (aggregate) builds the current major is the snapshot - // in 'versioned_docs/' served at the bare '/docs' (the default), and - // the live 'content/' is the other major at '/docs/v{other}'. Both - // are derived from 'VORTEX_CURRENT_MAJOR' - no manual edits to flip. - ...(versioned ? { - lastVersion: currentDocsVersion, - versions: { - [currentDocsVersion]: { - label: `v${currentMajor}`, - }, - current: { - label: `v${otherMajor}`, - path: `v${otherMajor}`, - banner: otherIsNewer ? 'unreleased' : 'unmaintained', - }, - }, - } : {}), + ...docsVersions, }, blog: false, theme: { @@ -180,10 +144,7 @@ const config = { position: 'right', title: 'Join us on Slack', }, - ...(versioned ? [{ - type: 'docsVersionDropdown', - position: 'right', - }] : []), + ...versionNavbarItems, { type: 'search', position: 'right', @@ -257,12 +218,7 @@ const config = { '@docusaurus/plugin-client-redirects', { redirects: [ - // The current major is the default at the bare '/docs', so its - // explicit '/docs/v{current}' path redirects there. - ...(versioned ? [{ - from: `/docs/v${currentMajor}`, - to: '/docs', - }] : []), + ...versionRedirects, { from: ['/quickstart'], to: '/docs', diff --git a/.vortex/docs/versions.js b/.vortex/docs/versions.js new file mode 100644 index 000000000..4c249fc62 --- /dev/null +++ b/.vortex/docs/versions.js @@ -0,0 +1,33 @@ +// Documentation version resolution. +// +// 'VORTEX_CURRENT_MAJOR' names the major served as the default version at the +// bare '/docs'; the other major is served at '/docs/v{other}'. Bumping it +// promotes a new major. 'VORTEX_DOCS_COMBINED' opts into serving both majors +// from one build - unset, the site is a single unversioned set and neither +// export contributes anything. + +const combined = process.env.VORTEX_DOCS_COMBINED === '1'; +const currentMajor = process.env.VORTEX_CURRENT_MAJOR || '1'; +const otherMajor = currentMajor === '1' ? '2' : '1'; +const currentVersion = `${currentMajor}.x`; + +/** Version options for the docs preset. */ +export const docsVersions = combined ? { + lastVersion: currentVersion, + versions: { + [currentVersion]: { + label: `v${currentMajor}`, + }, + current: { + label: `v${otherMajor}`, + path: `v${otherMajor}`, + banner: Number(otherMajor) > Number(currentMajor) ? 'unreleased' : 'unmaintained', + }, + }, +} : {}; + +/** Navbar items for the version switcher. */ +export const versionNavbarItems = combined ? [{type: 'docsVersionDropdown', position: 'right'}] : []; + +/** Redirects from the current major's explicit path to the default it is served at. */ +export const versionRedirects = combined ? [{from: `/docs/v${currentMajor}`, to: '/docs'}] : [];