diff --git a/.github/workflows/publish-documentation.yaml b/.github/workflows/publish-documentation.yaml index e4eb297e0..b8d20f053 100644 --- a/.github/workflows/publish-documentation.yaml +++ b/.github/workflows/publish-documentation.yaml @@ -1,31 +1,306 @@ name: Publish Documentation on: push: - branches: - - main + workflow_dispatch: + inputs: + deploy_as_release: + description: 'Deploy as release version (e.g., v1.2.3) - will deploy the docs as if this is a release with this version' + required: false + type: string + default: '' + force_deploy: + description: 'Force deployment even on non-main branches (for testing)' + required: false + type: boolean + default: false + skip_build: + description: 'Skip build-and-test job (use existing artifacts)' + required: false + type: boolean + default: false + workflow_call: + inputs: + deploy_as_release: + description: 'Deploy as release version (e.g., v1.2.3) - will deploy the docs as if this is a release with this version' + required: false + type: string + default: '' + skip_build: + description: 'Skip build-and-test job (assumes artifacts already exist)' + required: false + type: boolean + default: false + +env: + PYTHON_VERSION: '3.11' + NODE_VERSION: '20' + VERSIONED_BUCKET_NAME: simpl-element-release + CLOUDFRONT_DOMAIN: d2uqfzn4lxgtwv.cloudfront.net jobs: build-and-test: + if: ${{ !inputs.skip_build && github.event.inputs.skip_build != 'true' }} uses: ./.github/workflows/build-and-test.yaml secrets: SIEMENS_NPM_TOKEN: ${{ secrets.SIEMENS_NPM_TOKEN }} SIEMENS_NPM_USER: ${{ secrets.SIEMENS_NPM_USER }} MAPTILER_KEY: ${{ secrets.MAPTILER_KEY }} - publish-documentation: + # Simple deployment for main branch (no versioning) + publish-documentation-main: runs-on: ubuntu-24.04 + needs: + - build-and-test + if: >- + ${{ + needs.build-and-test.result == 'success' && + !inputs.deploy_as_release + }} permissions: + contents: write pages: write id-token: write - needs: - - build-and-test steps: - - uses: actions/configure-pages@v5 - - uses: actions/download-artifact@v7 + - name: Download documentation artifact + uses: actions/download-artifact@v7 with: name: pages path: dist/design - - uses: actions/upload-pages-artifact@v4 + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 with: path: 'dist/design' - - uses: actions/deploy-pages@v4 + + - name: Deploy to GitHub Pages + uses: actions/deploy-pages@v4 + + determine-documentation-versions: + needs: + - build-and-test + if: ${{ always() && (needs.build-and-test.result == 'success' || inputs.skip_build) && inputs.deploy_as_release }} + permissions: + contents: read + runs-on: ubuntu-24.04 + outputs: + deploy_major: ${{ steps.deploy.outputs.deploy_major }} + major_version: ${{ steps.deploy.outputs.major_version }} + version: ${{ steps.deploy.outputs.version }} + should_deploy: ${{ steps.deploy.outputs.should_deploy }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine deployment targets + id: deploy + run: | + # Initialize all outputs + DEPLOY_MAJOR="false" + MAJOR_VERSION="" + VERSION="" + SHOULD_DEPLOY="false" + + DEPLOY_AS_RELEASE="${{ github.event.inputs.deploy_as_release || inputs.deploy_as_release }}" + + # Validate and extract version info + if [[ -n "$DEPLOY_AS_RELEASE" ]]; then + # Ensure version starts with 'v' + if [[ ! "$DEPLOY_AS_RELEASE" =~ ^v[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then + echo "Error: Deploy as release version must be in format 'v1.2.3' or 'v1.2.3-suffix'" + echo "Provided: '$DEPLOY_AS_RELEASE'" + exit 1 + fi + + VERSION="${DEPLOY_AS_RELEASE#v}" + MAJOR_VERSION="v$(echo "$VERSION" | cut -d. -f1)" + + # Check if it's a pre-release (contains -, like v1.0.0-rc1) + IS_PRERELEASE="false" + if [[ "$VERSION" =~ -.*$ ]]; then + IS_PRERELEASE="true" + fi + + # Deploy to major version except for pre/next/rc releases + if [[ "$IS_PRERELEASE" == "false" ]]; then + DEPLOY_MAJOR="true" + SHOULD_DEPLOY="true" + fi + fi + + echo "deploy_major=$DEPLOY_MAJOR" >> $GITHUB_OUTPUT + echo "major_version=$MAJOR_VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "should_deploy=$SHOULD_DEPLOY" >> $GITHUB_OUTPUT + + echo "DEPLOYMENT PLAN" + echo "===============" + echo "Branch: ${{ github.ref }}" + echo "Trigger: ${{ github.event_name }}" + echo "Major ($MAJOR_VERSION): $DEPLOY_MAJOR" + + # Versioned deployment for releases (S3 only, not GitHub Pages) + publish-documentation-release: + runs-on: ubuntu-24.04 + needs: + - build-and-test + - determine-documentation-versions + # always() is required because build-and-test may be skipped (when skip_build=true) + # We still want to run if determine-documentation-versions succeeded + if: >- + ${{ + always() && + needs.determine-documentation-versions.result == 'success' && + needs.determine-documentation-versions.outputs.should_deploy == 'true' + }} + permissions: + contents: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download documentation artifact + uses: actions/download-artifact@v7 + with: + name: pages + path: new-docs + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v5.1.1 + with: + role-to-assume: arn:aws:iam::974483672234:role/simpl-element-release + role-session-name: element-release-docs + aws-region: eu-west-1 + + - name: Download existing versions from S3 + run: | + mkdir -p deploy-site + + # Download all existing documentation from S3 (as backup/source of truth) + echo "Downloading existing versions from S3..." + aws s3 cp s3://${{ env.VERSIONED_BUCKET_NAME }}/ deploy-site/ --recursive --quiet || echo "No existing versions found" + + - name: Update with new version(s) + run: | + # Deploy to major version directory + MAJOR_VERSION="${{ needs.determine-documentation-versions.outputs.major_version }}" + + echo "Updating /$MAJOR_VERSION/..." + # Remove old version to ensure clean deployment + rm -rf "deploy-site/$MAJOR_VERSION" + mkdir -p "deploy-site/$MAJOR_VERSION" + cp -r new-docs/* "deploy-site/$MAJOR_VERSION/" + + # Add base tag to docs HTML files only (exclude element-examples, dashboards-demo, coverage) + find "deploy-site/$MAJOR_VERSION" -name "*.html" -type f \ + ! -path "*/element-examples/*" \ + ! -path "*/dashboards-demo/*" \ + ! -path "*/coverage/*" \ + -exec sed -i.bak "s||\n |" {} \; + + find "deploy-site/$MAJOR_VERSION" -name "*.bak" -type f -delete + + - name: Generate versions.json + run: | + # Generate versions.json from actual folder structure in deploy-site + echo "Generating versions.json from deploy-site folder structure..." + + VERSIONS='[]' + + # Add preview as a redirect to element.siemens.io + echo "Adding: preview (redirect to https://element.siemens.io)" + VERSIONS=$(echo "$VERSIONS" | jq '. += [{"version": "https://element.siemens.io", "title": "Preview", "aliases": []}]') + + # Find all version-specific folders (v1, v2, v48, etc.) + for version_dir in deploy-site/v*/; do + if [[ -d "$version_dir" ]]; then + version_name=$(basename "$version_dir") + version_num=$(echo "$version_name" | sed 's/^v//') + echo "Found: $version_name" + VERSIONS=$(echo "$VERSIONS" | jq --arg version "$version_name" --arg title "v$version_num" '. += [{"version": $version, "title": $title, "aliases": []}]') + fi + done + + # Sort versions using the merge-versions.mjs script's sort logic + # Create a temporary script to sort the versions + cat > sort-versions.mjs << 'EOFSCRIPT' + import { readFileSync, writeFileSync } from 'fs'; + + const sortKey = (version) => { + // Preview (URL) comes first + if (version.startsWith('http')) return [0, 0]; + + const match = version.match(/^v(\d+)(?:\D|$)/); + if (match) { + return [1, -parseInt(match[1], 10)]; + } + + return [2, version]; + }; + + const sortVersions = (versions) => { + return versions.sort((a, b) => { + const [groupA, valueA] = sortKey(a.version); + const [groupB, valueB] = sortKey(b.version); + + if (groupA !== groupB) return groupA - groupB; + if (valueA === valueA) return 0; + return valueA < valueB ? -1 : 1; + }); + }; + + const versions = JSON.parse(readFileSync(process.argv[2], 'utf-8')); + const sorted = sortVersions(versions); + writeFileSync(process.argv[3], JSON.stringify(sorted, null, 2)); + console.log('Sorted versions:', sorted.map(v => v.version).join(', ')); + EOFSCRIPT + + echo "$VERSIONS" > unsorted-versions.json + node sort-versions.mjs unsorted-versions.json deploy-site/versions.json + + echo "Generated versions.json:" + cat deploy-site/versions.json + + - name: Update canonical URLs + run: | + SITE_URL="https://element.siemens.io/" + + # Update canonical URLs for version directories + MAJOR_VERSION="${{ needs.determine-documentation-versions.outputs.major_version }}" + VERSION_URL="${SITE_URL}${MAJOR_VERSION}/" + + find "deploy-site/$MAJOR_VERSION" -name "*.html" -type f -exec sed -i.bak \ + -e "s|/dev/null || true + + - name: Upload to S3 + run: | + echo "Uploading versioned documentation to S3..." + + MAJOR_VERSION="${{ needs.determine-documentation-versions.outputs.major_version }}" + echo "Uploading /$MAJOR_VERSION/..." + if [[ ! -d "deploy-site/$MAJOR_VERSION" ]]; then + echo "Error: deploy-site/$MAJOR_VERSION directory does not exist" + exit 1 + fi + aws s3 sync --quiet --no-progress --delete "deploy-site/$MAJOR_VERSION/" "s3://${{ env.VERSIONED_BUCKET_NAME }}/$MAJOR_VERSION/" + + # Upload versions.json with short cache-control for quick updates + if [[ ! -f "deploy-site/versions.json" ]]; then + echo "Error: deploy-site/versions.json file does not exist" + exit 1 + fi + aws s3 cp deploy-site/versions.json s3://${{ env.VERSIONED_BUCKET_NAME }}/versions.json \ + --cache-control "max-age=60,public" + + echo "Uploaded versioned documentation to S3 at s3://${{ env.VERSIONED_BUCKET_NAME }}/" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8a431c3e9..48700bac6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -16,6 +16,9 @@ jobs: - build-and-test permissions: id-token: write + outputs: + new_release: ${{ steps.check_release.outputs.new_release }} + release_tag: ${{ steps.check_release.outputs.release_tag }} steps: - uses: actions/checkout@v6 with: @@ -30,6 +33,13 @@ jobs: name: dist path: dist - run: npm ci --prefer-offline --no-audit + + - name: Get tag before semantic-release + id: before_release + run: | + BEFORE_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + echo "before_tag=$BEFORE_TAG" >> $GITHUB_OUTPUT + - run: npx semantic-release env: SKIP_COMMIT: ${{ github.ref_name == 'next' && 'true' || '' }} @@ -38,3 +48,28 @@ jobs: GIT_COMMITTER_NAME: 'Siemens Element Bot' GIT_COMMITTER_EMAIL: 'simpl.si@siemens.com' GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Check if new release was created + id: check_release + run: | + AFTER_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + BEFORE_TAG="${{ steps.before_release.outputs.before_tag }}" + + if [[ -n "$AFTER_TAG" && "$AFTER_TAG" != "$BEFORE_TAG" ]]; then + echo "release_tag=$AFTER_TAG" >> $GITHUB_OUTPUT + echo "new_release=true" >> $GITHUB_OUTPUT + else + echo "new_release=false" >> $GITHUB_OUTPUT + echo "release_tag=" >> $GITHUB_OUTPUT + fi + + trigger-documentation: + uses: ./.github/workflows/publish-documentation.yaml + needs: + - publish + - build-and-test + if: success() && needs.publish.outputs.new_release == 'true' + with: + deploy_as_release: ${{ needs.publish.outputs.release_tag }} + skip_build: true diff --git a/docs/_src/version-selector.js b/docs/_src/version-selector.js new file mode 100644 index 000000000..8fdc603a3 --- /dev/null +++ b/docs/_src/version-selector.js @@ -0,0 +1,167 @@ +/** + * Version Selector for Siemens Element Documentation + * + * This script implements a version switcher that: + * - Fetches versions.json from the root of the domain + * - Supports absolute version URLs + * - Preserves current page path when switching versions + * - Gracefully degrades if versions.json is not found (no errors, just no selector) + * + * If versions.json is not available (404), the page loads normally without the version selector. + * No errors are thrown to ensure documentation remains accessible. + * + * Based on MkDocs Material's version selector implementation + * https://github.com/squidfunk/mkdocs-material + */ + +(function () { + 'use strict'; + + /** + * Get the base URL of the site (root domain) + */ + function getBaseURL() { + const location = window.location; + return `${location.protocol}//${location.host}`; + } + + /** + * Get current version from URL path + * Only recognizes versions that exist in versions.json + * Returns empty string if at root (no version in path) + */ + function getCurrentVersion(versions) { + const path = window.location.pathname; + + // If we don't have versions yet, try to extract from path + if (!versions) { + const match = path.match(/\/([^/]+)\//); + return match ? match[1] : ''; + } + + // Check if any known version is in the path + for (const version of versions) { + if (version.version && path.includes(`/${version.version}/`)) { + return version.version; + } + } + + // No version found in path, assume root-level version + return ''; + } + + /** + * Get current page path relative to version + */ + function getCurrentPagePath(currentVersion) { + const path = window.location.pathname; + + // If no version (root-level), return the full path + if (!currentVersion || currentVersion === '') { + return path.substring(1); // Remove leading slash + } + + // Find version in path and get everything after it + const versionIndex = path.indexOf(`/${currentVersion}/`); + if (versionIndex !== -1) { + return path.substring(versionIndex + currentVersion.length + 2); + } + + return ''; + } + + /** + * Build version URL with current page path + * Supports versions at root (empty string), in subdirectories, or absolute URLs + */ + function buildVersionURL(version, currentVersion, preservePath = true) { + // If version is an absolute URL, return as-is + if ( + version.startsWith('http://') || + version.startsWith('https://') || + version.startsWith('//') + ) { + return version; + } + + const baseURL = getBaseURL(); + const pagePath = preservePath ? getCurrentPagePath(currentVersion) : ''; + + // If version is empty string or "/", host at root + if (!version || version === '/' || version === '') { + return `${baseURL}/${pagePath}`; + } + + return `${baseURL}/${version}/${pagePath}`; + } + + /** + * Render version selector HTML + */ + function renderVersionSelector(versions, currentVersion) { + const current = versions.find(v => v.version === currentVersion) || versions[0]; + const visibleVersions = versions.filter(v => !v.hidden); + + const html = `
`; + + return html; + } + + /** + * Initialize version selector + */ + function initVersionSelector() { + const baseURL = getBaseURL(); + const versionsURL = `${baseURL}/versions.json`; + + fetch(versionsURL) + .then(response => { + if (!response.ok) { + if (response.status === 404) { + return null; + } + throw new Error(`Failed to fetch versions.json: ${response.status}`); + } + return response.json(); + }) + .then(versions => { + if (!versions) { + return; + } + + if (!Array.isArray(versions) || versions.length === 0) { + console.warn('[Version Selector] versions.json is empty or invalid'); + return; + } + + // Get current version after we have the versions list + const currentVersion = getCurrentVersion(versions); + + // Find the .md-header element + const header = document.querySelector('.md-header'); + if (!header) { + console.warn('[Version Selector] .md-header element not found'); + return; + } + + // Create .md-header__topic wrapper with version selector inside + const html = renderVersionSelector(versions, currentVersion); + const topicWrapper = document.createElement('div'); + topicWrapper.className = 'md-header__topic'; + topicWrapper.innerHTML = html; + + // Append to .md-header + header.appendChild(topicWrapper); + }) + .catch(error => { + console.error('[Version Selector] Failed to load:', error.message); + }); + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initVersionSelector); + } else { + initVersionSelector(); + } +})(); diff --git a/docs/_src/versions.example.json b/docs/_src/versions.example.json new file mode 100644 index 000000000..a05b58ee5 --- /dev/null +++ b/docs/_src/versions.example.json @@ -0,0 +1,18 @@ +[ + { + "version": "", + "title": "Latest" + }, + { + "version": "v18", + "title": "18.x" + }, + { + "version": "v17", + "title": "17.x" + }, + { + "version": "https://element.siemens.io", + "title": "Preview" + } +] diff --git a/mkdocs.yml b/mkdocs.yml index dacb61308..1c54350a9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -225,6 +225,7 @@ extra_javascript: - '//w3.siemens.com/ote/ote_config.js' - '//w3.siemens.com/ote/sinet/ote.js' - 'https://assets.adobedtm.com/5dfc7d97c6fb/f16b45bec907/launch-af252bb19983.min.js' + - '_src/version-selector.js' extra: links: - name: 'GitHub' diff --git a/pyproject.toml b/pyproject.toml index 6af26dd81..95395dca0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = "MIT" requires-python = ">=3.11, <4" readme = "README.md" dependencies = [ - "mkdocs-code-siemens-code-docs-theme>=7.7.0,<8", + "mkdocs-code-siemens-code-docs-theme>=7.8.0,<8", "mkdocs-minify-html-plugin==0.3.9", "mkdocs-element-docs-builder", ] diff --git a/uv.lock b/uv.lock index 7241d79fc..7a81a8e5b 100644 --- a/uv.lock +++ b/uv.lock @@ -120,7 +120,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "mkdocs-code-siemens-code-docs-theme", specifier = ">=7.7.0,<8", index = "https://code.siemens.com/api/v4/groups/3259/-/packages/pypi/simple" }, + { name = "mkdocs-code-siemens-code-docs-theme", specifier = ">=7.8.0,<8", index = "https://code.siemens.com/api/v4/groups/3259/-/packages/pypi/simple" }, { name = "mkdocs-element-docs-builder", editable = "projects/element-docs-builder" }, { name = "mkdocs-minify-html-plugin", specifier = "==0.3.9" }, ] @@ -259,15 +259,15 @@ wheels = [ [[package]] name = "mkdocs-code-siemens-code-docs-theme" -version = "7.7.0" +version = "7.8.0" source = { registry = "https://code.siemens.com/api/v4/groups/3259/-/packages/pypi/simple" } dependencies = [ { name = "mkdocs" }, { name = "mkdocs-material" }, ] -sdist = { url = "https://code.siemens.com/api/v4/groups/3259/-/packages/pypi/files/5466e241cd053e818300f964f74e70764f988156ba6d0eb042364be23828728b/mkdocs_code_siemens_code_docs_theme-7.7.0.tar.gz", hash = "sha256:5466e241cd053e818300f964f74e70764f988156ba6d0eb042364be23828728b" } +sdist = { url = "https://code.siemens.com/api/v4/groups/3259/-/packages/pypi/files/e3b7946b001b72cb17cc129c7af2ad748812d34a200ef51265a6253fcbc30648/mkdocs_code_siemens_code_docs_theme-7.8.0.tar.gz", hash = "sha256:e3b7946b001b72cb17cc129c7af2ad748812d34a200ef51265a6253fcbc30648" } wheels = [ - { url = "https://code.siemens.com/api/v4/groups/3259/-/packages/pypi/files/68573928ab9d3c5bf14ca954481629c9512c9c837d4f3cdc15fa72eb54f47bb7/mkdocs_code_siemens_code_docs_theme-7.7.0-py3-none-any.whl", hash = "sha256:68573928ab9d3c5bf14ca954481629c9512c9c837d4f3cdc15fa72eb54f47bb7" }, + { url = "https://code.siemens.com/api/v4/groups/3259/-/packages/pypi/files/035a290de19b47fb31e9a91cc9c88083ce7baadde41f1a57b2237b8f59f080e3/mkdocs_code_siemens_code_docs_theme-7.8.0-py3-none-any.whl", hash = "sha256:035a290de19b47fb31e9a91cc9c88083ce7baadde41f1a57b2237b8f59f080e3" }, ] [[package]] @@ -297,7 +297,7 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.6.16" +version = "9.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -312,9 +312,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/84/aec27a468c5e8c27689c71b516fb5a0d10b8fca45b9ad2dd9d6e43bc4296/mkdocs_material-9.6.16.tar.gz", hash = "sha256:d07011df4a5c02ee0877496d9f1bfc986cfb93d964799b032dd99fe34c0e9d19", size = 4028828, upload-time = "2025-07-26T15:53:47.542Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/3b/111b84cd6ff28d9e955b5f799ef217a17bc1684ac346af333e6100e413cb/mkdocs_material-9.7.0.tar.gz", hash = "sha256:602b359844e906ee402b7ed9640340cf8a474420d02d8891451733b6b02314ec", size = 4094546, upload-time = "2025-11-11T08:49:09.73Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/f4/90ad67125b4dd66e7884e4dbdfab82e3679eb92b751116f8bb25ccfe2f0c/mkdocs_material-9.6.16-py3-none-any.whl", hash = "sha256:8d1a1282b892fe1fdf77bfeb08c485ba3909dd743c9ba69a19a40f637c6ec18c", size = 9223743, upload-time = "2025-07-26T15:53:44.236Z" }, + { url = "https://files.pythonhosted.org/packages/04/87/eefe8d5e764f4cf50ed91b943f8e8f96b5efd65489d8303b7a36e2e79834/mkdocs_material-9.7.0-py3-none-any.whl", hash = "sha256:da2866ea53601125ff5baa8aa06404c6e07af3c5ce3d5de95e3b52b80b442887", size = 9283770, upload-time = "2025-11-11T08:49:06.26Z" }, ] [[package]]