From 64d2ac15c4c4c9c71db237ab8ad75cac796fa96e Mon Sep 17 00:00:00 2001 From: Martin Trigaux Date: Mon, 14 Feb 2022 13:07:29 +0000 Subject: [PATCH] [IMP] odoo_theme: introduce fallback URLs for the page switchers When a user clicks on the link of an alternate page in the version or language switcher, we now check if the page referenced by the target URL exists or not. If not, we generate a series of fallback URLs from the target URL and check whether the targeted resource exists or not, until we read the root of the documentation. As soon as we find a valid URL, we redirect the user to it. This is inspired by the behaviour of docs.python.org's version and language switchers. task-2534669 closes odoo/documentation#2042 X-original-commit: 25e863a64cb5d5baa00eee4d9a0e10f3231ee38a Signed-off-by: Antoine Vandevenne (anv) Signed-off-by: Martin Trigaux (mat) Co-authored-by: Antoine Vandevenne (anv) --- extensions/odoo_theme/__init__.py | 1 + extensions/odoo_theme/static/js/switchers.js | 27 ++++++ extensions/odoo_theme/static/js/utils.js | 97 ++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 extensions/odoo_theme/static/js/switchers.js diff --git a/extensions/odoo_theme/__init__.py b/extensions/odoo_theme/__init__.py index 5ae55222c..694475c22 100644 --- a/extensions/odoo_theme/__init__.py +++ b/extensions/odoo_theme/__init__.py @@ -14,6 +14,7 @@ def setup(app): app.add_js_file('js/layout.js') app.add_js_file('js/menu.js') app.add_js_file('js/page_toc.js') + app.add_js_file('js/switchers.js') return { 'parallel_read_safe': True, diff --git a/extensions/odoo_theme/static/js/switchers.js b/extensions/odoo_theme/static/js/switchers.js new file mode 100644 index 000000000..e34bbe8d8 --- /dev/null +++ b/extensions/odoo_theme/static/js/switchers.js @@ -0,0 +1,27 @@ +(function ($) { + + document.addEventListener('DOMContentLoaded', () => { + // Enable fallback URLs for broken redirects from the version or language switchers. + _prepareSwitchersFallbacks(); + }); + + /** + * Add event listeners on links in the version and language switchers. + * + * If a link is clicked, the user is redirected to the closest fallback URL (including the + * original target URL) that is available. + */ + const _prepareSwitchersFallbacks = () => { + document.querySelectorAll('a[class="dropdown-item"]').forEach(element => { + element.addEventListener('click', async event => { + if (element.hasAttribute('href')) { + event.preventDefault(); + const fallbackUrls = await _generateFallbackUrls(element.getAttribute('href')); + const fallbackUrl = await _getFirstValidUrl(fallbackUrls); + window.location.href = fallbackUrl; + } + }); + }); + }; + +})(); diff --git a/extensions/odoo_theme/static/js/utils.js b/extensions/odoo_theme/static/js/utils.js index 63274e12e..b74a57301 100644 --- a/extensions/odoo_theme/static/js/utils.js +++ b/extensions/odoo_theme/static/js/utils.js @@ -63,3 +63,100 @@ const _prepareAccordion = (tocElement) => { tocEntryList.parentNode.insertBefore(tocEntryWrapper, tocEntryList); }); }; + +/** + * Generate a list of fallback URLs from the closest to the furthest of the target URL and + * return the first one that points to an existing resource. + * + * The generation consists of starting with the target URL and walking back toward the root of + * the documentation while alternating between including the original language or not, if it was + * included in the original URL. The last fallback URL is the root of the documentation with the + * version stripped off to redirect the user to the index of the default version. + * + * Example: + * 1. .../documentation/13.0/contributing/documentation.html + * 2. .../documentation/13.0/contributing.html + * 3. .../documentation/13.0 + * 4. .../documentation/ + * + * Example: + * 1. .../documentation/15.0/fr/administration/install.html + * 2. .../documentation/15.0/administration/install.html + * 3. .../documentation/15.0/fr/administration.html + * 4. .../documentation/15.0/administration.html + * 5. .../documentation/15.0/fr/ + * 6. .../documentation/15.0/ + * 7. .../documentation/ + */ +const _generateFallbackUrls = async (targetUrl) => { + + const _deconstructUrl = (urlObject) => { + let version = ''; + let language = ''; + const originalPathParts = []; + for (let fragment of urlObject.pathname.split('/').reverse()) { + if (fragment.match(/^(?:saas-)?\d{2}\.\d$|^master$/)) { + version = fragment; + } else if (fragment.match(/^[a-z]{2}(?:_[A-Z]{2})?$/)) { + language = fragment; + } else if (fragment.length > 0) { + originalPathParts.unshift(fragment); + } + } + return [version, language, originalPathParts]; + }; + + const targetUrlObject = new URL(targetUrl); + const [version, language, originalPathParts] = _deconstructUrl(targetUrlObject); + const urlBase = targetUrlObject.origin; + + // Generate the fallback URLs. + const fallbackUrls = []; + for (let i = originalPathParts.length; i >= 0; i--) { + const fallbackPathParts = originalPathParts.slice(0, i); + + // Append '.html' to the last path part if it is missing and the part is not the root. + if ( + fallbackPathParts.length > 0 + && !fallbackPathParts[fallbackPathParts.length - 1].endsWith('.html') + ) { + fallbackPathParts[fallbackPathParts.length - 1] += '.html'; + } + + // Build the fallback URL from the version, language and path parts, if any. + if (version && language) + fallbackUrls.push( + `${urlBase}/${version}/${language}/${fallbackPathParts.join('/')}`, + `${urlBase}/${version}/${fallbackPathParts.join('/')}`, + ); + else if (version && !language) + fallbackUrls.push(`${urlBase}/${version}/${fallbackPathParts.join('/')}`); + else if (!version && language) + fallbackUrls.push( + `${urlBase}/${language}/${fallbackPathParts.join('/')}`, + `${urlBase}/${fallbackPathParts.join('/')}`, + ); + else if (!version && !language) + fallbackUrls.push(`${urlBase}/${fallbackPathParts.join('/')}`); + } + return fallbackUrls; +}; + +/** + * Iterate over the provided URLs and return the first one that points to a valid resource. + * + * Since URLs don't have a protocol and cannot be fetched when the documentation is built locally + * without the `ROOT` and `IS_REMOTE_BUILD` Make arguments, the URLs that don't have the protocol + * 'http' or 'https' are not tested. + */ +const _getFirstValidUrl = async (urls) => { + for (let url of urls) { + if (url.startsWith('http')) { + const response = await fetch(url); + if (response.ok) { + return url; + } + } + } + return urls[0]; // No valid URL found, return the first one. +};