diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9695178..c9d72ed 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [ main, develop ] +permissions: + actions: read + contents: write + jobs: docs: runs-on: ubuntu-latest @@ -21,6 +25,193 @@ jobs: - name: Install Antora run: npm install -g @antora/cli@3.1 @antora/site-generator@3.1 + - name: Generate downloads manifest and ZIPs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + node <<'EOF' + ;(async () => { + const fs = require('fs/promises') + const path = require('path') + + const ownerRepo = process.env.GITHUB_REPOSITORY || '' + const [owner, repo] = ownerRepo.split('/') + const token = process.env.GITHUB_TOKEN + const apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com' + const eventName = process.env.GITHUB_EVENT_NAME + const refName = process.env.GITHUB_REF_NAME + const sha = process.env.GITHUB_SHA + const workspace = process.env.GITHUB_WORKSPACE + const eventPath = process.env.GITHUB_EVENT_PATH + const event = JSON.parse(await fs.readFile(eventPath, 'utf8')) + const branch = eventName === 'pull_request' ? event.pull_request?.head?.ref : refName + const prNumber = event.pull_request?.number ?? null + const outputRoot = path.join(workspace, 'supplemental-ui', 'downloads') + const generatedRoot = path.join(outputRoot, 'generated') + const maxPollAttempts = 30 + // Poll every 20 seconds to give the build/test workflows time to finish + // and upload artifacts without delaying the docs workflow excessively. + const pollIntervalMs = 20000 + + await fs.mkdir(generatedRoot, { recursive: true }) + for (const entry of await fs.readdir(generatedRoot)) { + if (entry !== '.gitkeep') { + await fs.rm(path.join(generatedRoot, entry), { force: true, recursive: true }) + } + } + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + const sanitize = (value) => value.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') + const platformLabel = (value) => value + .replace(/-latest$/i, '') + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') + + const apiFetch = async (url, options = {}) => { + const response = await fetch(url, { + ...options, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...(options.headers || {}), + }, + }) + if (!response.ok) { + throw new Error(`${url} -> ${response.status} ${response.statusText}`) + } + return response + } + + const listRuns = async (workflowFile) => { + const params = new URLSearchParams({ + branch, + event: eventName, + per_page: '50', + }) + const response = await apiFetch(`${apiUrl}/repos/${owner}/${repo}/actions/workflows/${workflowFile}/runs?${params}`) + const payload = await response.json() + return payload.workflow_runs || [] + } + + const matchesContext = (run) => { + if (!run) return false + if (run.head_sha === sha) return true + if (eventName === 'pull_request' && prNumber) { + return Array.isArray(run.pull_requests) && run.pull_requests.some((pullRequest) => pullRequest.number === prNumber) + } + return run.head_branch === branch + } + + const findContextRun = async (workflowFile, title) => { + let fallbackRun = null + for (let attempt = 0; attempt < maxPollAttempts; attempt += 1) { + const runs = await listRuns(workflowFile) + const contextRuns = runs.filter(matchesContext) + const contextRun = contextRuns.find((run) => run.head_sha === sha) || contextRuns[0] + + if (!fallbackRun) { + fallbackRun = contextRuns.find((run) => run.status === 'completed' && run.conclusion === 'success') || null + } + + if (contextRun) { + if (contextRun.status === 'completed') { + if (contextRun.conclusion === 'success') return contextRun + console.warn(`${title} run for ${sha} completed with ${contextRun.conclusion}`) + return null + } + } + + if (attempt < maxPollAttempts - 1) { + console.log(`Waiting for ${title} workflow artifacts for ${branch} (${attempt + 1}/${maxPollAttempts})...`) + await sleep(pollIntervalMs) + } + } + + if (fallbackRun) { + console.warn(`Using latest successful ${title} run for the current ${eventName === 'pull_request' ? 'pull request' : 'branch'} context.`) + return fallbackRun + } + + console.warn(`Timed out waiting for ${title} workflow run on ${branch}`) + return null + } + + const listArtifacts = async (runId) => { + const response = await apiFetch(`${apiUrl}/repos/${owner}/${repo}/actions/runs/${runId}/artifacts?per_page=100`) + const payload = await response.json() + return payload.artifacts || [] + } + + const downloadArtifact = async (artifact, fileName) => { + const response = await apiFetch(artifact.archive_download_url) + const buffer = Buffer.from(await response.arrayBuffer()) + await fs.writeFile(path.join(generatedRoot, fileName), buffer) + } + + const sectionSpecs = [ + { id: 'build', title: 'Build', workflow: 'build.yml', prefix: 'build-' }, + { id: 'tests', title: 'Tests', workflow: 'test.yml', prefix: 'test-results-' }, + ] + + const sections = [] + for (const spec of sectionSpecs) { + const run = await findContextRun(spec.workflow, spec.title) + if (!run) { + sections.push({ id: spec.id, title: spec.title, items: [] }) + continue + } + + const artifacts = (await listArtifacts(run.id)) + .filter((artifact) => !artifact.expired && artifact.name.startsWith(spec.prefix)) + .sort((left, right) => left.name.localeCompare(right.name)) + + const items = [] + for (const artifact of artifacts) { + const fileName = `${sanitize(spec.id)}-${sanitize(artifact.name)}-run${run.run_number}.zip` + await downloadArtifact(artifact, fileName) + items.push({ + id: artifact.id, + name: artifact.name, + label: artifact.name, + platform: platformLabel(artifact.name.slice(spec.prefix.length)), + run_number: run.run_number, + created_at: artifact.created_at, + size_in_bytes: artifact.size_in_bytes, + expired: artifact.expired, + download: `${artifact.name}.zip`, + url: `generated/${fileName}`, + }) + } + + sections.push({ + id: spec.id, + title: spec.title, + workflow_run_id: run.id, + workflow_run_number: run.run_number, + items, + }) + } + + const manifest = { + generated_at: new Date().toISOString(), + context: { + repository: ownerRepo, + event: eventName, + ref: branch, + sha, + pr_number: prNumber, + }, + sections, + } + + await fs.writeFile(path.join(outputRoot, 'downloads-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`) + })().catch((error) => { + console.error(error) + process.exitCode = 1 + }) + EOF + - name: Build Documentation run: antora antora-playbook.yml diff --git a/antora-playbook.yml b/antora-playbook.yml index 3b8f8f8..3a465dd 100644 --- a/antora-playbook.yml +++ b/antora-playbook.yml @@ -13,6 +13,7 @@ ui: bundle: url: https://gitlab.com/antora/antora-ui-default/-/jobs/artifacts/HEAD/raw/build/ui-bundle.zip?job=bundle-stable snapshot: true + supplemental_files: ./supplemental-ui output: dir: ./build/site diff --git a/supplemental-ui/css/custom.css b/supplemental-ui/css/custom.css new file mode 100644 index 0000000..f1c817f --- /dev/null +++ b/supplemental-ui/css/custom.css @@ -0,0 +1,90 @@ +.prostep-logo-link { + padding-right: 0.25rem; +} + +.prostep-logo { + display: block; + height: 2rem; + width: auto; +} + +.downloads-toggle { + appearance: none; + background: none; + border: 0; + color: inherit; + cursor: pointer; + font: inherit; + padding: 0; +} + +.downloads-dropdown-menu { + min-width: 20rem; + max-width: 24rem; +} + +.downloads-status, +.downloads-section-title, +.downloads-empty { + white-space: normal; +} + +.downloads-status.is-error { + color: #b42318; +} + +.downloads-section-title { + color: #6b7280; + cursor: default; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.downloads-artifact { + align-items: flex-start; + display: block; + line-height: 1.35; + white-space: normal; +} + +.downloads-artifact-label, +.downloads-artifact-meta { + display: block; +} + +.downloads-artifact-label { + font-weight: 600; +} + +.downloads-artifact-meta { + color: #6b7280; + font-size: 0.85em; + margin-top: 0.15rem; +} + +.navbar-item.has-dropdown.is-active .navbar-dropdown { + display: block; +} + +.navbar-item.github svg { + display: block; +} + +.navbar-item.is-disabled { + cursor: default; + opacity: 0.8; + pointer-events: none; +} + +@media screen and (max-width: 1023px) { + .prostep-logo { + height: 1.75rem; + } + + .downloads-dropdown-menu { + max-width: none; + min-width: 100%; + } +} diff --git a/supplemental-ui/downloads/downloads-manifest.json b/supplemental-ui/downloads/downloads-manifest.json new file mode 100644 index 0000000..72d129f --- /dev/null +++ b/supplemental-ui/downloads/downloads-manifest.json @@ -0,0 +1,22 @@ +{ + "generated_at": null, + "context": { + "repository": "PMSFIT/smartse-template", + "event": null, + "ref": null, + "sha": null, + "pr_number": null + }, + "sections": [ + { + "id": "build", + "title": "Build", + "items": [] + }, + { + "id": "tests", + "title": "Tests", + "items": [] + } + ] +} diff --git a/supplemental-ui/downloads/generated/.gitkeep b/supplemental-ui/downloads/generated/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/supplemental-ui/img/prostep_logo.svg b/supplemental-ui/img/prostep_logo.svg new file mode 100644 index 0000000..2dbb6f1 --- /dev/null +++ b/supplemental-ui/img/prostep_logo.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + diff --git a/supplemental-ui/js/downloads.js b/supplemental-ui/js/downloads.js new file mode 100644 index 0000000..7aff61a --- /dev/null +++ b/supplemental-ui/js/downloads.js @@ -0,0 +1,164 @@ +(() => { + const dropdowns = document.querySelectorAll('[data-downloads-dropdown]') + if (!dropdowns.length) return + + const formatDate = (value) => { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(date) + } + + const renderMessage = (menu, message, extraClass = '') => { + const node = document.createElement('div') + node.className = `navbar-item is-disabled downloads-status${extraClass ? ` ${extraClass}` : ''}` + node.textContent = message + menu.replaceChildren(node) + } + + const createArtifactLink = (item) => { + const link = document.createElement('a') + link.className = 'navbar-item downloads-artifact' + link.href = item.url + link.setAttribute('download', item.download || '') + + const label = document.createElement('span') + label.className = 'downloads-artifact-label' + label.textContent = item.label || item.name || 'Download artifact' + link.appendChild(label) + + const metaParts = [] + if (item.platform) metaParts.push(item.platform) + if (item.run_number) metaParts.push(`Run #${item.run_number}`) + const createdAt = formatDate(item.created_at) + if (createdAt) metaParts.push(createdAt) + if (metaParts.length) { + const meta = document.createElement('span') + meta.className = 'downloads-artifact-meta' + meta.textContent = metaParts.join(' • ') + link.appendChild(meta) + } + + return link + } + + const renderManifest = (menu, manifest) => { + const sections = Array.isArray(manifest?.sections) ? manifest.sections : [] + const fragment = document.createDocumentFragment() + let hasArtifacts = false + + sections.forEach((section) => { + const items = Array.isArray(section?.items) + ? section.items.filter((item) => item?.url && !item?.expired) + : [] + if (!items.length) return + + if (hasArtifacts) { + const divider = document.createElement('hr') + divider.className = 'navbar-divider' + fragment.appendChild(divider) + } + + const title = document.createElement('div') + title.className = 'navbar-item is-disabled downloads-section-title' + title.textContent = section.title || 'Downloads' + fragment.appendChild(title) + + items.forEach((item) => { + fragment.appendChild(createArtifactLink(item)) + }) + + hasArtifacts = true + }) + + if (!hasArtifacts) { + renderMessage(menu, 'No downloads available for this branch or pull request.') + return + } + + menu.replaceChildren(fragment) + } + + const closeDropdown = (container, toggle, focusToggle = false) => { + container.classList.remove('is-active') + toggle.setAttribute('aria-expanded', 'false') + if (focusToggle) toggle.focus() + } + + const openDropdown = (container, toggle, focusFirst = false) => { + container.classList.add('is-active') + toggle.setAttribute('aria-expanded', 'true') + if (focusFirst) { + const firstLink = container.querySelector('.downloads-artifact') + if (firstLink) firstLink.focus() + } + } + + dropdowns.forEach((container) => { + const toggle = container.querySelector('.downloads-toggle') + const menu = container.querySelector('[data-downloads-menu]') + const manifestUrl = container.dataset.manifestUrl + if (!toggle || !menu || !manifestUrl) return + + const absoluteManifestUrl = new URL(manifestUrl, window.location.href) + + toggle.addEventListener('click', () => { + if (container.classList.contains('is-active')) { + closeDropdown(container, toggle) + } else { + openDropdown(container, toggle) + } + }) + + toggle.addEventListener('keydown', (event) => { + if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + openDropdown(container, toggle, true) + } else if (event.key === 'Escape') { + event.preventDefault() + closeDropdown(container, toggle) + } + }) + + menu.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.preventDefault() + closeDropdown(container, toggle, true) + } + }) + + menu.addEventListener('click', (event) => { + if (event.target.closest('a')) closeDropdown(container, toggle) + }) + + document.addEventListener('click', (event) => { + if (!container.contains(event.target)) closeDropdown(container, toggle) + }) + + fetch(absoluteManifestUrl, { + headers: { Accept: 'application/json' }, + cache: 'no-cache', + }) + .then((response) => { + if (!response.ok) throw new Error(`Manifest request failed with ${response.status} ${response.statusText}`) + return response.json() + }) + .then((manifest) => { + const normalizedSections = (manifest.sections || []).map((section) => ({ + ...section, + items: (section.items || []).map((item) => ({ + ...item, + url: new URL(item.url, absoluteManifestUrl).toString(), + })), + })) + renderManifest(menu, { ...manifest, sections: normalizedSections }) + }) + .catch(() => { + renderMessage(menu, 'Failed to load downloads. Please refresh the page or try again later.', 'is-error') + }) + }) +})() diff --git a/supplemental-ui/partials/footer-scripts.hbs b/supplemental-ui/partials/footer-scripts.hbs new file mode 100644 index 0000000..ed7f49b --- /dev/null +++ b/supplemental-ui/partials/footer-scripts.hbs @@ -0,0 +1,6 @@ + + + +{{#if env.SITE_SEARCH_PROVIDER}} +{{> search-scripts}} +{{/if}} diff --git a/supplemental-ui/partials/head-styles.hbs b/supplemental-ui/partials/head-styles.hbs new file mode 100644 index 0000000..3e9d47d --- /dev/null +++ b/supplemental-ui/partials/head-styles.hbs @@ -0,0 +1,2 @@ + + diff --git a/supplemental-ui/partials/header-content.hbs b/supplemental-ui/partials/header-content.hbs new file mode 100644 index 0000000..2786451 --- /dev/null +++ b/supplemental-ui/partials/header-content.hbs @@ -0,0 +1,41 @@ +
+ +