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. +};