From 58939e10eeb7433abb2904ea36fc85f765788b9c Mon Sep 17 00:00:00 2001 From: Thibault Saunier Date: Sat, 18 Jan 2025 13:55:36 -0300 Subject: [PATCH] docs: Embed the gstreamer-rs documentation into our documentation in CI Downloading the latest build of GStreamer-rs from its CI job and running a script to embed the rustoc generated documentation into hotdoc based one. Part-of: --- ci/docker/fedora/install-deps.sh | 2 +- ci/scripts/build-docs.sh | 3 + .../scripts/assets/css/rustdoc-in-hotdoc.css | 31 + .../scripts/assets/js/language-menu.js | 92 +++ .../scripts/assets/js/sitemap-rs-fixer.js | 76 +++ .../gst-docs/scripts/assets/js/theme-sync.js | 34 ++ .../gst-docs/scripts/rust_doc_unifier.py | 564 ++++++++++++++++++ 7 files changed, 801 insertions(+), 1 deletion(-) create mode 100644 subprojects/gst-docs/scripts/assets/css/rustdoc-in-hotdoc.css create mode 100644 subprojects/gst-docs/scripts/assets/js/language-menu.js create mode 100644 subprojects/gst-docs/scripts/assets/js/sitemap-rs-fixer.js create mode 100644 subprojects/gst-docs/scripts/assets/js/theme-sync.js create mode 100755 subprojects/gst-docs/scripts/rust_doc_unifier.py diff --git a/ci/docker/fedora/install-deps.sh b/ci/docker/fedora/install-deps.sh index 625b43c9f6..38c73407c1 100644 --- a/ci/docker/fedora/install-deps.sh +++ b/ci/docker/fedora/install-deps.sh @@ -28,7 +28,7 @@ dnf install -y glib2-doc gdk-pixbuf2-devel gtk3-devel-docs gtk4-devel-docs libso # Make sure we don't end up installing these from some transient dependency dnf remove -y "gstreamer1*-devel" rust cargo meson 'fdk-aac-free*' -pip3 install meson==1.5.2 python-gitlab tomli junitparser +pip3 install meson==1.5.2 python-gitlab tomli junitparser bs4 pip3 install git+https://github.com/hotdoc/hotdoc.git@8c1cc997f5bc16e068710a8a8121f79ac25cbcce # Install most debug symbols, except the big ones from things we use diff --git a/ci/scripts/build-docs.sh b/ci/scripts/build-docs.sh index 6ba6cb6817..991c7011bb 100755 --- a/ci/scripts/build-docs.sh +++ b/ci/scripts/build-docs.sh @@ -32,3 +32,6 @@ export GI_TYPELIB_PATH=$PWD/girs hotdoc run --conf-file build/subprojects/gst-docs/GStreamer-doc.json mv "$builddir/subprojects/gst-docs/GStreamer-doc/html" documentation/ + +pip3 install bs4 +python3 subprojects/gst-docs/scripts/rust_doc_unifier.py documentation/ diff --git a/subprojects/gst-docs/scripts/assets/css/rustdoc-in-hotdoc.css b/subprojects/gst-docs/scripts/assets/css/rustdoc-in-hotdoc.css new file mode 100644 index 0000000000..5021f8e1be --- /dev/null +++ b/subprojects/gst-docs/scripts/assets/css/rustdoc-in-hotdoc.css @@ -0,0 +1,31 @@ +body.rustdoc { + padding-top: 50px; +} + +/* Revert hotdoc body font size setting */ +:root { + font-size: 16px; +} + +/* Media queries for responsive behavior */ +@media (max-width: 767px) { + .gst-navbar-toggle { + display: block; + } +} + +@media (max-width: 701px) { + .sidebar { + padding-top: 50px; + } +} + +@media (min-width: 700px) { + .sidebar { + position: sticky; + top: 50px; + height: calc(100vh - 50px); + overflow-y: auto; + } +} + diff --git a/subprojects/gst-docs/scripts/assets/js/language-menu.js b/subprojects/gst-docs/scripts/assets/js/language-menu.js new file mode 100644 index 0000000000..0813c405b3 --- /dev/null +++ b/subprojects/gst-docs/scripts/assets/js/language-menu.js @@ -0,0 +1,92 @@ +// Wait for the language dropdown to be created +const language_observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.addedNodes.length) { + const languageDropdown = document.querySelector('ul.dropdown-menu li a[href*="gi-language="]'); + if (languageDropdown) { + // Found the language dropdown, so we can add Rust + const dropdownMenu = languageDropdown.closest('ul.dropdown-menu'); + if (dropdownMenu && !dropdownMenu.querySelector('a[href*="rust"]')) { + // Create new list item for Rust + const rustItem = document.createElement('li'); + const rustLink = document.createElement('a'); + rustLink.href = 'rust/stable/latest/docs/index.html?gi-language=rust'; + rustLink.textContent = 'rust'; + rustItem.appendChild(rustLink); + dropdownMenu.appendChild(rustItem); + + // Disconnect language_observer since we've done our work + language_observer.disconnect(); + } + } + } + }); +}); + + +function transformVersionMenu(versions) { + if (!versions) { + console.error("hotdoc rustdoc version could not be loaded"); + return; + } + // Find the menu + const menu = document.getElementById('API versions-menu'); + if (!menu) { + console.error("API versions menu not found"); + return; + } + + // Remove the Reset option and divider + const resetItem = menu.querySelector('li:first-child'); + const divider = menu.querySelector('.divider'); + if (resetItem) resetItem.remove(); + if (divider) divider.remove(); + + for (const [key, value] of Object.entries(versions)) { + const link = Array.from(menu.getElementsByTagName('a')) + .find(a => a.textContent.trim() === key); + + assert(link); + link.href = value; + } +} + +// Start observing the navbar for changes +document.addEventListener('DOMContentLoaded', () => { + const navbar = document.querySelector('#navbar-wrapper'); + if (navbar) { + language_observer.observe(navbar, { + childList: true, + subtree: true + }); + } + + let versions = JSON.parse(document.getElementById('hotdoc-rust-info') + .getAttribute("hotdoc-rustdoc-versions") + .replace(/'/g, '"')); + + + createTagsDropdown({ "API versions": Object.keys(versions) }); + + transformVersionMenu(versions); +}); + +function syncSidenavIframeParams() { + const params = new URLSearchParams(window.location.search); + const language = params.get('gi-language'); + const frame = document.getElementById('sitenav-frame'); + + if (frame) { + const frameUrl = new URL(frame.src, window.location.origin); + if (language) { + frameUrl.searchParams.set('gi-language', language); + } else { + frameUrl.searchParams.delete('gi-language'); + } + frame.src = frameUrl.toString(); + } +} + +document.addEventListener('DOMContentLoaded', syncSidenavIframeParams); + + diff --git a/subprojects/gst-docs/scripts/assets/js/sitemap-rs-fixer.js b/subprojects/gst-docs/scripts/assets/js/sitemap-rs-fixer.js new file mode 100644 index 0000000000..dd0ee6a0f8 --- /dev/null +++ b/subprojects/gst-docs/scripts/assets/js/sitemap-rs-fixer.js @@ -0,0 +1,76 @@ +function modifyLibsPanel() { + // Check if we should show Rust API + const params = new URLSearchParams(window.location.search); + var language = params.get('gi-language'); + + if (!language) { + language = utils.getStoredLanguage(); + } + + if (language === 'rust') { + let apiref = document.querySelector('a[data-nav-ref="gi-extension-GStreamer-libs.html"]'); + apiref.innerText = "Rust crates"; + apiref.href = "rust/stable/latest/docs/index.html?gi-language=rust"; + + // Add crates to the panel + const crates = CRATES_LIST; // This will be replaced by Python + const renames = CRATES_RENAMES; // This will be replaced by Python + + const rootDiv = document.querySelector('div[data-nav-ref="gi-extension-GStreamer-libs.html"]'); + if (!rootDiv) { + console.error('Root div not found'); + return; + } + // Now iterate first level panel bodies to make links point to rust + // crates + const firstLevelPanels = rootDiv.querySelectorAll(':scope > div.sidenav-panel-body'); + firstLevelPanels.forEach((panel, index) => { + if (index >= crates.length) { + panel.remove(); + return; + } + + const crate = crates[index]; + const link = panel.querySelector('a'); + + if (link) { + // Update href + link.setAttribute('href', `rust/stable/latest/docs/${crate}/index.html?gi-language=rust`); + + // Update text content + link.textContent = renames[crate] || + crate.replace("gstreamer", "") + .replace(/_/g, " ") + .trim() + .split(" ") + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + + // Remove children of the library item + const navRef = link.getAttribute('data-nav-ref'); + if (navRef) { + const siblingDiv = rootDiv.querySelector(`div[data-nav-ref="${navRef}"]`); + if (siblingDiv) { + siblingDiv.remove(); + } + } + + // And now remove the glyphicons (arrows) as we removed the + // children + const linkContainer = link.closest('div'); + if (linkContainer) { + const glyphicons = linkContainer.querySelectorAll('.glyphicon'); + glyphicons.forEach(icon => icon.remove()); + } + + } + }); + } +} + +// Run when page loads and when URL changes +document.addEventListener('DOMContentLoaded', modifyLibsPanel); +window.addEventListener('popstate', modifyLibsPanel); +window.addEventListener('pushstate', modifyLibsPanel); +window.addEventListener('replacestate', modifyLibsPanel); + diff --git a/subprojects/gst-docs/scripts/assets/js/theme-sync.js b/subprojects/gst-docs/scripts/assets/js/theme-sync.js new file mode 100644 index 0000000000..c9fe57ff1a --- /dev/null +++ b/subprojects/gst-docs/scripts/assets/js/theme-sync.js @@ -0,0 +1,34 @@ +function syncThemeWithHotdoc() { + // Get the current stylesheet + const currentStyle = document.querySelector('link[rel="stylesheet"][href*="frontend"]'); + if (!currentStyle) return; + + // Check if we're using dark theme in hotdoc + const isDark = getActiveStyleSheet() == 'dark'; + + // Use rustdoc's switchTheme function to set the theme + let newThemeName = isDark ? 'dark' : 'light'; + window.switchTheme(newThemeName, true); +} + +// Run on page load +document.addEventListener('DOMContentLoaded', () => { + localStorage.setItem("rustdoc-use-system-theme", false); + syncThemeWithHotdoc(); + + // Watch for theme changes in hotdoc + const theme_observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') { + syncThemeWithHotdoc(); + } + }); + }); + + // Start observing theme changes + const styleLink = document.querySelector('link[rel="stylesheet"][href*="frontend"]'); + if (styleLink) { + theme_observer.observe(styleLink, { attributes: true }); + } +}); + diff --git a/subprojects/gst-docs/scripts/rust_doc_unifier.py b/subprojects/gst-docs/scripts/rust_doc_unifier.py new file mode 100755 index 0000000000..8d884146b4 --- /dev/null +++ b/subprojects/gst-docs/scripts/rust_doc_unifier.py @@ -0,0 +1,564 @@ +#!/usr/bin/env python3 + +import os +import sys +import json +import html +import shutil +from collections import OrderedDict +from pathlib import Path +from bs4 import BeautifulSoup +import zipfile +from urllib.request import urlretrieve +from multiprocessing import Pool, cpu_count, Manager +from functools import partial +import traceback +import gitlab + + +def get_documentation_artifact_url(project_name='gstreamer/gstreamer', + job_name='documentation', + branch='main') -> str: + """ + Returns the URL of the latest artifact from GitLab for the specified job. + + Args: + project_name (str): Name of the GitLab project + job_name (str): Name of the job + branch (str): Name of the git branch + """ + gl = gitlab.Gitlab("https://gitlab.freedesktop.org/") + project = gl.projects.get(project_name) + pipelines = project.pipelines.list(get_all=False) + for pipeline in pipelines: + if pipeline.ref != branch: + continue + + job, = [j for j in pipeline.jobs.list(iterator=True) + if j.name == job_name] + if job.status != "success": + continue + + return f"https://gitlab.freedesktop.org/{project_name}/-/jobs/{job.id}/artifacts/download" + + raise Exception("Could not find documentation artifact") + + +def get_relative_prefix(file_path, docs_root): + """ + Returns the relative path prefix for a given HTML file. + + Args: + file_path (Path): Path to the HTML file + docs_root (Path): Root directory of the documentation + """ + rel_path = os.path.relpath(docs_root, file_path.parent) + if rel_path == '.': + return './' + return '../' + '../' * rel_path.count(os.sep) + + +def fix_relative_urls(element, prefix): + """ + Fixes relative URLs in a hotdoc component to include the correct prefix. + + Args: + element: BeautifulSoup element containing hotdoc navigation or resources + prefix: Prefix to add to relative URLs + """ + # Fix href attributes + for tag in element.find_all(True, {'href': True}): + url = tag['href'] + if url.startswith(('http://', 'https://', 'mailto:', '#', 'javascript:')): + continue + + if url.endswith('/') or '.' not in url.split('/')[-1]: + if not url.endswith('index.html'): + url = url.rstrip('/') + '/index.html' + + if ".html" in url and '?gi-language=' not in url: + url += '?gi-language=rust' + + tag['href'] = prefix + url + + # Fix src attributes + for tag in element.find_all(True, {'src': True}): + url = tag['src'] + if not url.startswith(('http://', 'https://', 'data:', 'javascript:')): + if '?gi-language=' not in url: + url += '?gi-language=rust' + tag['src'] = prefix + url + + +def extract_hotdoc_resources(index_html_soup, prefix): + """ + Extracts required CSS and JS resources from the main hotdoc page. + Returns tuple of (css_links, js_scripts) + """ + head = index_html_soup.find('head') + + # Extract CSS links + css_links = [link for link in head.find_all('link') if 'enable_search.css' + not in link['href']] + + # Extract JS scripts + js_scripts = [] + for script in head.find_all('script'): + src = script.get('src', '') + if [unwanted for unwanted in ["trie_index.js", "prism-console-min.js", 'trie.js', 'language-menu.js'] if unwanted in src]: + continue + + if 'language_switching.js' in script['src']: + js_scripts.append(BeautifulSoup(' +''', 'html.parser')) + + js_scripts.append(script) + + return css_links, js_scripts + + +def extract_hotdoc_nav(index_html_soup): + """ + Extracts the navigation bar from the main GStreamer page. + Returns the navigation HTML. + """ + nav = index_html_soup.find('nav', class_='navbar') + + for tag in nav.find_all(True, {'href': True}): + url = tag['href'] + if "gstreamer/gi-index.html" in url: + tag['href'] = "rust/stable/latest/docs/gstreamer/index.html" + elif "libs.html" in url: + tag['href'] = "rust/stable/latest/docs/index.html" + + return nav + + +def get_hotdoc_components(docs_root, prefix): + """ + Reads the main GStreamer page and extracts required components. + + Returns tuple of (resources_html, nav_html) + """ + index_path = docs_root / "index.html" + with open(index_path, 'r', encoding='utf-8') as f: + content = f.read() + + soup = BeautifulSoup(content, 'html.parser') + + # Extract resources and navigation first + css_links, js_scripts = extract_hotdoc_resources(soup, prefix) + nav = extract_hotdoc_nav(soup) + if not css_links: + raise Exception("Failed to extract CSS links") + if not js_scripts: + raise Exception("Failed to extract JS scripts") + if not nav: + raise Exception("Failed to extract navigation") + + resources_soup = BeautifulSoup("
", 'html.parser') + assert resources_soup.div + for component in css_links + js_scripts: + resources_soup.div.append(component) + + # Fix URLs in the extracted components + fix_relative_urls(resources_soup, prefix) + fix_relative_urls(nav, prefix) + + # Build final HTML + resources_html = "\n".join(str(tag) for tag in resources_soup.div.contents) + resources_html += f'\n