and blocks.
+include_verbatim = false
+
+############################# Exclusions ##########################
+
+# Exclude URLs from checking (treated as regular expressions).
+# External-host exclusions (bot-blocks, redirects, false positives) live in .lycheeignore.
+# Only structural patterns that depend on the build path are kept here.
+exclude = [
+ # Internal file:// links are resolved by lychee from root-relative hrefs via root_dir.
+ # They point to the local MkDocs output and are already validated by `mkdocs build --strict`.
+ # Cross-doc links produced by the remap rules below go through the `repositories/`
+ # subdirectory and are therefore NOT matched by this pattern.
+ "^file://__BASE_DIR__/site/",
+ # Versionless project root links (e.g. /projects/connect, /projects/userguide) appear in
+ # the MkDocs theme sidebar as cross-project navigation and are not real content links.
+ # The https form appears as absolute links; the file:// form appears after root_dir resolution
+ # of root-relative hrefs like /projects/connect in the built HTML.
+ "^https?://doc\\.ibexa\\.co/projects/[^/]+/?$",
+ "^file://.*?/site/projects/[^/]+/?$",
+]
+
+# Exclude these input paths from being scanned.
+exclude_path = [
+ # Search index, assets and sitemap contain no meaningful external links
+ "site/search/search_index.json",
+ "site/assets",
+ "site/sitemap.xml",
+ # PHP API reference HTML is generated by phpDocumentor and uses to resolve
+ # relative links — lychee does not honour tags and would flag them as broken.
+ # Links pointing *to* these files from other pages are still checked.
+ "site/api/php_api/php_api_reference/",
+]
+
+# Check the specified file extensions
+extensions = ["html"]
+
+# Exclude all private IPs from checking.
+exclude_all_private = true
+
+############################# Local files #########################
+
+# Required to resolve root-relative links (e.g. href="/…") found in every page into
+# file:// URLs. Those file:// URLs are then excluded from checking by the pattern in
+# the exclude list — internal links are validated by `mkdocs build --strict`.
+root_dir = "site"
+
+############################# Remap ###############################
+
+# Rewrite doc.ibexa.co links to locally-built MkDocs sites, avoiding HTTP
+# requests to Cloudflare-protected hosts.
+#
+# - en/latest/ → current build's site/ directory (self-referential links from cards() macro)
+# - en/4.6/ → repositories/devdoc-4.6/site/
+# - en/5.0/ → repositories/devdoc-5.0/site/
+# - userguide/en/latest/ → repositories/userdoc-5.0/site/ (newest available)
+# - userguide/en/4.6/ → repositories/userdoc-4.6/site/
+# - userguide/en/5.0/ → repositories/userdoc-5.0/site/
+# - connect/en/latest/ → repositories/connect/site/
+#
+# Three patterns per version handle all URL shapes.
+# NOTE: lychee applies remap BEFORE stripping the fragment, so each pattern uses
+# an optional (#.*)? group to capture and forward fragments to the local file path.
+#
+# Pattern 1 — direct .html files (e.g. REST/PHP API reference):
+# ([^#]+\.html)(#.*)?$
+#
+# Pattern 2 — directory paths (with or without trailing slash, with path segments):
+# ([^#/]+(?:/[^#/]+)*)/?(#.*)?$
+# $1 captures the path without a trailing slash; replacement always adds /index.html.
+#
+# Pattern 3 — bare version root (e.g. /en/5.0/ with nothing after):
+# $ (exact match of the version prefix)
+remap = [
+ # GitHub line-number fragments (#L7, #L12-L15) are rendered via JavaScript —
+ # lychee cannot find them in the raw HTML. Strip the fragment so the file
+ # existence is still verified.
+ "^(https://github\\.com/[^#]+)#L[0-9].* $1",
+ # MDN URLs without a locale redirect to the browser's preferred language.
+ # Remap to en-US so lychee can verify the link without following the redirect.
+ "^https://developer\\.mozilla\\.org/docs/(.+) https://developer.mozilla.org/en-US/docs/$1",
+ # doc.ibexa.co/en/latest/ links are self-referential: the cards() macro generates
+ # them when READTHEDOCS_VERSION_NAME is not set (e.g. in CI). Remap to the locally
+ # built site/ directory. These file:// URLs are then matched by the exclude pattern
+ # above and skipped — internal links are validated by `mkdocs build --strict`.
+ "https://doc\\.ibexa\\.co/en/latest/([^#]+\\.html)(#.*)?$ file://__BASE_DIR__/site/$1$2",
+ "https://doc\\.ibexa\\.co/en/latest/([^#/]+(?:/[^#/]+)*)/?(#.*)?$ file://__BASE_DIR__/site/$1/index.html$2",
+ "https://doc\\.ibexa\\.co/en/latest/$ file://__BASE_DIR__/site/index.html",
+ # userguide en/latest/ — map to the newest available clone (userdoc-5.0)
+ "https://doc\\.ibexa\\.co/projects/userguide/en/latest/([^#]+\\.html)(#.*)?$ file://__BASE_DIR__/repositories/userdoc-5.0/site/$1$2",
+ "https://doc\\.ibexa\\.co/projects/userguide/en/latest/([^#/]+(?:/[^#/]+)*)/?(#.*)?$ file://__BASE_DIR__/repositories/userdoc-5.0/site/$1/index.html$2",
+ "https://doc\\.ibexa\\.co/projects/userguide/en/latest/$ file://__BASE_DIR__/repositories/userdoc-5.0/site/index.html",
+ # devdoc 4.6
+ "https://doc\\.ibexa\\.co/en/4\\.6/([^#]+\\.html)(#.*)?$ file://__BASE_DIR__/repositories/devdoc-4.6/site/$1$2",
+ "https://doc\\.ibexa\\.co/en/4\\.6/([^#/]+(?:/[^#/]+)*)/?(#.*)?$ file://__BASE_DIR__/repositories/devdoc-4.6/site/$1/index.html$2",
+ "https://doc\\.ibexa\\.co/en/4\\.6/$ file://__BASE_DIR__/repositories/devdoc-4.6/site/index.html",
+ # devdoc 5.0
+ "https://doc\\.ibexa\\.co/en/5\\.0/([^#]+\\.html)(#.*)?$ file://__BASE_DIR__/repositories/devdoc-5.0/site/$1$2",
+ "https://doc\\.ibexa\\.co/en/5\\.0/([^#/]+(?:/[^#/]+)*)/?(#.*)?$ file://__BASE_DIR__/repositories/devdoc-5.0/site/$1/index.html$2",
+ "https://doc\\.ibexa\\.co/en/5\\.0/$ file://__BASE_DIR__/repositories/devdoc-5.0/site/index.html",
+ # userdoc 4.6
+ "https://doc\\.ibexa\\.co/projects/userguide/en/4\\.6/([^#]+\\.html)(#.*)?$ file://__BASE_DIR__/repositories/userdoc-4.6/site/$1$2",
+ "https://doc\\.ibexa\\.co/projects/userguide/en/4\\.6/([^#/]+(?:/[^#/]+)*)/?(#.*)?$ file://__BASE_DIR__/repositories/userdoc-4.6/site/$1/index.html$2",
+ "https://doc\\.ibexa\\.co/projects/userguide/en/4\\.6/$ file://__BASE_DIR__/repositories/userdoc-4.6/site/index.html",
+ # userdoc 5.0
+ "https://doc\\.ibexa\\.co/projects/userguide/en/5\\.0/([^#]+\\.html)(#.*)?$ file://__BASE_DIR__/repositories/userdoc-5.0/site/$1$2",
+ "https://doc\\.ibexa\\.co/projects/userguide/en/5\\.0/([^#/]+(?:/[^#/]+)*)/?(#.*)?$ file://__BASE_DIR__/repositories/userdoc-5.0/site/$1/index.html$2",
+ "https://doc\\.ibexa\\.co/projects/userguide/en/5\\.0/$ file://__BASE_DIR__/repositories/userdoc-5.0/site/index.html",
+ # userdoc master — treated as the newest available (userdoc-5.0)
+ "https://doc\\.ibexa\\.co/projects/userguide/en/master/([^#]+\\.html)(#.*)?$ file://__BASE_DIR__/repositories/userdoc-5.0/site/$1$2",
+ "https://doc\\.ibexa\\.co/projects/userguide/en/master/([^#/]+(?:/[^#/]+)*)/?(#.*)?$ file://__BASE_DIR__/repositories/userdoc-5.0/site/$1/index.html$2",
+ "https://doc\\.ibexa\\.co/projects/userguide/en/master/$ file://__BASE_DIR__/repositories/userdoc-5.0/site/index.html",
+ # connect — single branch, mapped from both en/latest/
+ "https://doc\\.ibexa\\.co/projects/connect/en/latest/([^#]+\\.html)(#.*)?$ file://__BASE_DIR__/repositories/connect/site/$1$2",
+ "https://doc\\.ibexa\\.co/projects/connect/en/latest/([^#/]+(?:/[^#/]+)*)/?(#.*)?$ file://__BASE_DIR__/repositories/connect/site/$1/index.html$2",
+ "https://doc\\.ibexa\\.co/projects/connect/en/latest/$ file://__BASE_DIR__/repositories/connect/site/index.html",
+]
+
+############################# Hosts ###############################
+
+# Global limit: at most 2 simultaneous requests to any single host.
+host_concurrency = 2
+
+# Global minimum interval between requests to the same host.
+host_request_interval = "500ms"
+
diff --git a/main.py b/main.py
index 3c38638782c..c792ed64502 100644
--- a/main.py
+++ b/main.py
@@ -109,7 +109,7 @@ def cards(pages, columns=1, style="cards", force_version=False):
elif re.search(".html$", path):
html = True
content = open("docs/%s" % path, "r").read()
- page = '/'.join((
+ page = 'https:/' + '/'.join((
'/',
site,
language,
@@ -120,7 +120,7 @@ def cards(pages, columns=1, style="cards", force_version=False):
html = False
path = path.rstrip('/')
content = open("docs/%s.md" % path, "r").read()
- page = '/'.join((
+ page = 'https:/' + '/'.join((
'/',
site,
language,
diff --git a/mkdocs.yml b/mkdocs.yml
index 6ecfc86c8cf..39908acff67 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -2,6 +2,7 @@ INHERIT: plugins.yml
site_name: Developer Documentation
repo_url: https://github.com/ibexa/documentation-developer
+edit_uri: edit/5.0/docs
site_url: https://doc.ibexa.co/en/latest/
copyright: "Copyright 1999-2026 Ibexa AS and others"
validation:
diff --git a/tools/clone-repositories.sh b/tools/clone-repositories.sh
new file mode 100755
index 00000000000..68e6082a59d
--- /dev/null
+++ b/tools/clone-repositories.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+# Clones and builds versioned documentation repositories used by lychee's remap rules,
+# then generates lychee.toml from lychee.toml.dist with absolute paths substituted in.
+#
+# Usage: ./tools/clone-repositories.sh [DEVDOC_50_BRANCH] [DEVDOC_46_BRANCH] [USERDOC_50_BRANCH] [USERDOC_46_BRANCH] [CONNECT_BRANCH]
+#
+# DEVDOC_50_BRANCH Branch of ibexa/documentation-developer to use for 5.0 (default: 5.0)
+# DEVDOC_46_BRANCH Branch of ibexa/documentation-developer to use for 4.6 (default: 4.6)
+# USERDOC_50_BRANCH Branch of ibexa/documentation-user to use for 5.0 (default: 5.0)
+# USERDOC_46_BRANCH Branch of ibexa/documentation-user to use for 4.6 (default: 4.6)
+# CONNECT_BRANCH Branch of ibexa/documentation-connect (default: main)
+#
+# Run this once before running lychee. Re-run to refresh clones or after moving
+# the repository to a new path (the path in lychee.toml will be updated automatically).
+
+set -euo pipefail
+
+DEVDOC_50_BRANCH="${1:-5.0}"
+DEVDOC_46_BRANCH="${2:-4.6}"
+USERDOC_50_BRANCH="${3:-5.0}"
+USERDOC_46_BRANCH="${4:-4.6}"
+CONNECT_BRANCH="${5:-main}"
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# The script lives in tools/; all paths (repositories/, lychee.toml.dist, lychee.toml)
+# are relative to the repository root one level up.
+REPO_DIR="$(dirname "$SCRIPT_DIR")"
+
+# Prepend the local Python installation so pip/mkdocs resolve correctly.
+# Adjust this if your Python is installed elsewhere.
+export PATH="$HOME/python/bin:$PATH"
+
+cd "$REPO_DIR"
+
+echo "==> Cloning versioned repositories..."
+echo " devdoc 5.0 → branch '$DEVDOC_50_BRANCH'"
+echo " devdoc 4.6 → branch '$DEVDOC_46_BRANCH'"
+echo " userdoc 5.0 → branch '$USERDOC_50_BRANCH'"
+echo " userdoc 4.6 → branch '$USERDOC_46_BRANCH'"
+echo " connect → branch '$CONNECT_BRANCH'"
+mkdir -p repositories
+git clone --depth=1 --branch "$DEVDOC_46_BRANCH" https://github.com/ibexa/documentation-developer.git repositories/devdoc-4.6 &
+git clone --depth=1 --branch "$DEVDOC_50_BRANCH" https://github.com/ibexa/documentation-developer.git repositories/devdoc-5.0 &
+git clone --depth=1 --branch "$USERDOC_46_BRANCH" https://github.com/ibexa/documentation-user.git repositories/userdoc-4.6 &
+git clone --depth=1 --branch "$USERDOC_50_BRANCH" https://github.com/ibexa/documentation-user.git repositories/userdoc-5.0 &
+git clone --depth=1 --branch "$CONNECT_BRANCH" https://github.com/ibexa/documentation-connect.git repositories/connect &
+wait
+
+echo "==> Building versioned repositories..."
+for dir in repositories/devdoc-4.6 repositories/devdoc-5.0 repositories/userdoc-4.6 repositories/userdoc-5.0 repositories/connect; do
+ (cd "$dir" && pip install -q -r requirements.txt && mkdocs build --quiet) &
+done
+wait
+
+echo "==> Generating lychee.toml from lychee.toml.dist..."
+sed "s|__BASE_DIR__|$REPO_DIR|g" lychee.toml.dist > lychee.toml
+echo " __BASE_DIR__ → '$REPO_DIR'"
+
+echo "Done."