diff --git a/docs/css/custom.css b/docs/css/custom.css index fe9b85528a..0d7e34ea2e 100644 --- a/docs/css/custom.css +++ b/docs/css/custom.css @@ -158,7 +158,7 @@ body { } .md-typeset h1 { - margin: 0 0 1rem; + margin: 0 0 0.5rem; font-size: 24px; line-height: 34px; } @@ -649,3 +649,73 @@ div.path { [hidden] { display: none !important; } + +.page-actions { + display: none; + gap: 0; + margin: 12px 0 0 0; + padding-bottom: 6px; + border-bottom: 1px solid var(--ibexa-snow); +} + +.md-typeset .page-action-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + background: transparent; + border: none; + text-decoration: none; + color: var(--ibexa-dusk-black); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: color 0.15s ease; + white-space: nowrap; + position: relative; +} + +.page-action-btn+.page-action-btn::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + height: 16px; + width: 1px; + background: var(--ibexa-snow); +} + +.page-action-btn svg { + width: 14px; + height: 14px; + fill: currentColor; + flex-shrink: 0; +} + +.page-action-btn.external::after, +.page-action-btn[rel="nofollow"]::after { + display: none; +} + +@media (max-width: 768px) { + .page-actions { + gap: 0; + } +} + +.page-actions--visible { + display: flex; +} + +.page-action-btn.page-action-btn--success { + background: #d4edda; + border-color: #c3e6cb; + color: #155724; +} + +.page-action-btn.page-action-btn--info { + background: color-mix(in srgb, var(--turquoise-pearl) 25%, white); + border-color: color-mix(in srgb, var(--turquoise-pearl) 40%, white); + color: var(--sherpa-blue); +} diff --git a/docs/css/release-notes.css b/docs/css/release-notes.css index 8663f96563..bbf47f8821 100644 --- a/docs/css/release-notes.css +++ b/docs/css/release-notes.css @@ -2,7 +2,6 @@ display: flex; justify-content: space-between; align-items: center; - border-bottom: 1px solid #DCDDDE; padding: 8px 0; } .release-notes-header h1 { diff --git a/docs/personalization/api_reference/api_reference.md b/docs/personalization/api_reference/api_reference.md index 7910d07941..add35808d6 100644 --- a/docs/personalization/api_reference/api_reference.md +++ b/docs/personalization/api_reference/api_reference.md @@ -1,6 +1,7 @@ --- description: Explore Personalization API sets that let you manage item data, track events, combine tracking data with users and render recommendations. page_type: landing_page +exclude_from_llmstxt: true --- # Personalization API diff --git a/docs/personalization/api_reference/content_api.md b/docs/personalization/api_reference/content_api.md index 2477740618..6cdde12523 100644 --- a/docs/personalization/api_reference/content_api.md +++ b/docs/personalization/api_reference/content_api.md @@ -1,5 +1,6 @@ --- description: Personalization server can use external information about the items. Use HTTP methods to create, update or get items from the data store. +exclude_from_llmstxt: true --- # Content API diff --git a/docs/personalization/api_reference/recommendation_api.md b/docs/personalization/api_reference/recommendation_api.md index 42727e5e43..bb5a386043 100644 --- a/docs/personalization/api_reference/recommendation_api.md +++ b/docs/personalization/api_reference/recommendation_api.md @@ -1,6 +1,7 @@ --- description: Use HTTP GET request method to render recommendations. month_change: false +exclude_from_llmstxt: true --- # Recommendation API diff --git a/docs/personalization/api_reference/tracking_api.md b/docs/personalization/api_reference/tracking_api.md index 36d7c5058c..0aeaac3a73 100644 --- a/docs/personalization/api_reference/tracking_api.md +++ b/docs/personalization/api_reference/tracking_api.md @@ -1,5 +1,6 @@ --- description: Allows to track items based on an ID. It covers many content types with the same ID configured for tracking. +exclude_from_llmstxt: true --- # Tracking API diff --git a/docs/personalization/api_reference/user_api.md b/docs/personalization/api_reference/user_api.md index 710baa385a..04500defb4 100644 --- a/docs/personalization/api_reference/user_api.md +++ b/docs/personalization/api_reference/user_api.md @@ -1,5 +1,6 @@ --- description: Use HTTP methods to correlate metadata with user data and combine users into clusters of certain type. +exclude_from_llmstxt: true --- # User API diff --git a/docs/personalization/attribute_search_in_elasticsearch.md b/docs/personalization/attribute_search_in_elasticsearch.md index 01c2b6a710..da6eb2d6fe 100644 --- a/docs/personalization/attribute_search_in_elasticsearch.md +++ b/docs/personalization/attribute_search_in_elasticsearch.md @@ -1,5 +1,6 @@ --- description: Attribute search uses Elasticsearch database to display dynamically taken values in scenario and model previews. +exclude_from_llmstxt: true --- # Attribute search in Elasticsearch database diff --git a/docs/personalization/enable_personalization.md b/docs/personalization/enable_personalization.md index 7d3d2b4f17..2b46e89f3c 100644 --- a/docs/personalization/enable_personalization.md +++ b/docs/personalization/enable_personalization.md @@ -1,6 +1,7 @@ --- description: Configure your project files to enable Personalization and set up items you want to track. month_change: false +exclude_from_llmstxt: true --- # Enable Personalization diff --git a/docs/personalization/how_it_works.md b/docs/personalization/how_it_works.md index ee28481dce..5038284a70 100644 --- a/docs/personalization/how_it_works.md +++ b/docs/personalization/how_it_works.md @@ -1,5 +1,6 @@ --- description: Integrate recommendation service into your website. +exclude_from_llmstxt: true --- # How Personalization works diff --git a/docs/personalization/importing_historical_user_tracking_data.md b/docs/personalization/importing_historical_user_tracking_data.md index fd655a8e59..c3e3921f95 100644 --- a/docs/personalization/importing_historical_user_tracking_data.md +++ b/docs/personalization/importing_historical_user_tracking_data.md @@ -1,5 +1,6 @@ --- description: Use historical user tracking data to build user profiles and generate better recommendations. +exclude_from_llmstxt: true --- # Importing historical user tracking data diff --git a/docs/personalization/integrate_recommendation_service.md b/docs/personalization/integrate_recommendation_service.md index a2049f526c..9d38c2e394 100644 --- a/docs/personalization/integrate_recommendation_service.md +++ b/docs/personalization/integrate_recommendation_service.md @@ -1,6 +1,7 @@ --- description: Integrate recommendation service into your website. month_change: false +exclude_from_llmstxt: true --- # Integrate recommendation service diff --git a/docs/personalization/legacy_recommendation_api.md b/docs/personalization/legacy_recommendation_api.md index 5551d4323b..1886fbc792 100644 --- a/docs/personalization/legacy_recommendation_api.md +++ b/docs/personalization/legacy_recommendation_api.md @@ -1,5 +1,6 @@ --- description: An old method of fetching recommendations from the system using recommendation requests. +exclude_from_llmstxt: true --- # Legacy Recommendation API diff --git a/docs/personalization/personalization.md b/docs/personalization/personalization.md index 68ba2eafda..3a29c7fb9f 100644 --- a/docs/personalization/personalization.md +++ b/docs/personalization/personalization.md @@ -1,6 +1,7 @@ --- description: Personalization tracks consumed content and suggests targeted content to your website visitors. page_type: landing_page +exclude_from_llmstxt: true --- # Personalization diff --git a/docs/personalization/personalization_guide.md b/docs/personalization/personalization_guide.md index 0bb32baff3..ef8d041ea5 100644 --- a/docs/personalization/personalization_guide.md +++ b/docs/personalization/personalization_guide.md @@ -1,5 +1,6 @@ --- description: Discover Personalization - a cloud-based service that tracks and analyzes customer behaviors. +exclude_from_llmstxt: true --- # Personalization product guide diff --git a/docs/personalization/recommendation_integration.md b/docs/personalization/recommendation_integration.md index 769f3f1671..d53c930419 100644 --- a/docs/personalization/recommendation_integration.md +++ b/docs/personalization/recommendation_integration.md @@ -1,5 +1,6 @@ --- description: Methods for REST call with Personalization server. +exclude_from_llmstxt: true --- # Recommendation integration diff --git a/docs/personalization/tracking_integration.md b/docs/personalization/tracking_integration.md index 3ab8b63542..1ae6fa5f7b 100644 --- a/docs/personalization/tracking_integration.md +++ b/docs/personalization/tracking_integration.md @@ -1,6 +1,7 @@ --- description: See the methods of event tracking integration using tracking from server or from client-side. month_change: false +exclude_from_llmstxt: true --- # Tracking integration diff --git a/docs/personalization/tracking_with_ibexa-tracker.md b/docs/personalization/tracking_with_ibexa-tracker.md index 8357133ab7..11bacee7c9 100644 --- a/docs/personalization/tracking_with_ibexa-tracker.md +++ b/docs/personalization/tracking_with_ibexa-tracker.md @@ -1,6 +1,7 @@ --- description: Integrate tracking with a Google-style JavaScript. month_change: false +exclude_from_llmstxt: true --- # Track events with ibexa-tracker.js diff --git a/hooks.py b/hooks.py new file mode 100644 index 0000000000..0cc392ddfc --- /dev/null +++ b/hooks.py @@ -0,0 +1,144 @@ +""" +MkDocs hooks for Ibexa developer documentation. + +Automatically keeps the llmstxt plugin's ``sections`` config in sync with the ``nav`` defined in ``mkdocs.yml`` +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mkdocs.config.defaults import MkDocsConfig + +FRONTMATTER_EDITION_DISPLAY = { + "lts-update": "LTS Update", + "experience": "Experience", + "commerce": "Commerce", + "headless": "Headless", +} + + +def on_config(config: "MkDocsConfig") -> None: + """Populate llmstxt sections from nav before the build starts. + + The llmstxt plugin reads ``config.sections`` in its ``on_files`` event, + which fires after ``on_config``, so injecting here is the right place. + """ + nav = config.get("nav") + if not nav: + return + + llmstxt = config["plugins"].get("llmstxt") + if llmstxt is None: + return + + import sys + _here = Path(__file__).parent + if str(_here) not in sys.path: + sys.path.insert(0, str(_here)) + from update_llmstxt_config import convert_nav_to_llmstxt_sections + + docs_dir = Path(config["docs_dir"]) + llmstxt.config.sections = convert_nav_to_llmstxt_sections(nav, docs_dir) + + +def on_page_content(html: str, *, page: "Page", config: "MkDocsConfig", **kwargs) -> None: + """Reformat the Markdown content generated by the llmstxt plugin for this page. + + Hooks run after plugins for every event, so at this point the llmstxt + plugin has already generated Markdown and stored it in ``_md_pages``. + We reformat here so the plugin writes the corrected content in its own + ``on_post_build`` (which also runs before ours, for the same reason). + """ + llmstxt = config["plugins"].get("llmstxt") + if llmstxt is None: + return + + src_uri = page.file.src_uri + page_info = llmstxt._md_pages.get(src_uri) + if page_info is None: + return + + content = page_info.content + content = _inject_edition_badges(content, page, config) + reformatted = _renumber_ordered_lists(content) + if reformatted != content: + llmstxt._md_pages[src_uri] = page_info._replace(content=reformatted) + elif content != page_info.content: + llmstxt._md_pages[src_uri] = page_info._replace(content=content) + + +def on_post_build(*, config: "MkDocsConfig", **kwargs) -> None: + """No-op — reformatting is done per-page in on_page_content.""" + + +def _inject_edition_badges(content: str, page: "Page", config: "MkDocsConfig") -> str: + """Insert 'Editions: X, Y' line after the first h1 heading, from frontmatter.""" + src_path = Path(config["docs_dir"]) / page.file.src_path + try: + from mkdocs.utils import meta as mkdocs_meta + with open(src_path, encoding="utf-8") as f: + raw = f.read() + _, frontmatter = mkdocs_meta.get_data(raw) + except Exception: + return content + + def _to_list(value): + if isinstance(value, list): + return value + if isinstance(value, str): + return value.split() + return [] + + edition = frontmatter.get("edition") + editions = frontmatter.get("editions") or [] + all_editions = _to_list(edition) + _to_list(editions) + display = [FRONTMATTER_EDITION_DISPLAY.get(e, e) for e in all_editions if e] + if not display: + return content + + badge_line = "Editions: " + ", ".join(display) + + # Insert after the first h1 (# ...) line + lines = content.split("\n") + for i, line in enumerate(lines): + if line.startswith("# "): + lines.insert(i + 1, "") + lines.insert(i + 2, badge_line) + return "\n".join(lines) + + return badge_line + "\n\n" + content + + +def _renumber_ordered_lists(content: str) -> str: + """Replace repeated '1.' list markers with sequential numbers (1. 2. 3. ...).""" + lines = content.split("\n") + result = [] + counters: list[int] = [] # stack of counters per indent level + + for line in lines: + m = re.match(r'^(\s*)1\. (.*)$', line) + if m: + indent = len(m.group(1)) + level = indent // 2 # assume 2-space indent per level + + # Trim deeper levels from the stack + while len(counters) > level + 1: + counters.pop() + # Extend stack if we went deeper + while len(counters) <= level: + counters.append(0) + + counters[level] += 1 + result.append(f"{m.group(1)}{counters[level]}. {m.group(2)}") + else: + # Both blank lines and non-list lines reset the counter stack, + # since a blank line always separates distinct markdown lists. + counters.clear() + result.append(line) + + return "\n".join(result) + diff --git a/llmstxt_preprocess.py b/llmstxt_preprocess.py new file mode 100644 index 0000000000..560ef70bad --- /dev/null +++ b/llmstxt_preprocess.py @@ -0,0 +1,342 @@ +import html as html_module +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bs4 import BeautifulSoup + +PILL_CLASS_TO_EDITION = { + "pill--lts-update": "LTS Update", + "pill--experience": "Experience", + "pill--commerce": "Commerce", + "pill--headless": "Headless", + "pill--new-feature": "New feature", + "pill--first-release": "First release", +} + +FRONTMATTER_EDITION_DISPLAY = { + "lts-update": "LTS Update", + "experience": "Experience", + "commerce": "Commerce", + "headless": "Headless", +} + + +def preprocess(soup: "BeautifulSoup", output: str) -> None: + """ + Preprocess HTML to improve markdown conversion. + + Runs with autoclean disabled so we can control the order: + 1. Expand tabbed sets with labels before autoclean removes tabbed-labels. + 2. Run autoclean-equivalent cleanup. + 3. Replace inline edition badge spans with readable text. + 4. Remove release notes filter UI. + 5. Convert card macros to markdown lists. + + Note: frontmatter edition injection is handled in hooks.py on_page_content, + where page.file.src_path is available directly. + """ + _process_tabbed_sets(soup) + _autoclean(soup) + _process_inline_pills(soup) + _process_release_note_tags(soup) + _process_release_notes_filters(soup) + _process_cards(soup) + _process_info_tiles(soup) + _process_tables(soup) + _process_admonitions(soup) + _normalize_ordered_lists(soup) + + +# --------------------------------------------------------------------------- +# Ordered lists +# --------------------------------------------------------------------------- + +def _normalize_ordered_lists(soup: "BeautifulSoup") -> None: + """Remove start/value attributes from
elements in table cells.
+ # html.parser silently drops (invalid void-closing tag), so adjacent
+ # blocks have no separator and markdownify concatenates their backticks.
+ for td in soup.find_all(["td", "th"]):
+ children = list(td.children)
+ for i in range(len(children) - 1):
+ curr = children[i]
+ nxt = children[i + 1]
+ if getattr(curr, "name", None) == "code" and getattr(nxt, "name", None) == "code":
+ curr.insert_after(NavigableString(", "))
+
+ for element in soup.find_all("div", attrs={"class": "doc-md-description"}):
+ element.replace_with(NavigableString(element.get_text().strip()))
+
+ for element in soup.find_all("span", attrs={"class": "doc-labels"}):
+ element.decompose()
+
+ for element in soup.find_all("table", attrs={"class": "highlighttable"}):
+ code_elem = element.find("code")
+ if code_elem:
+ element.replace_with(
+ Soup(f"{html_module.escape(code_elem.get_text())}", "html.parser")
+ )
+
+# ---------------------------------------------------------------------------
+# Inline edition badge spans (from snippet includes)
+# ---------------------------------------------------------------------------
+
+def _process_inline_pills(soup: "BeautifulSoup") -> None:
+ """Replace inline edition pill spans with readable text, e.g. '(Experience)'."""
+ for span in soup.find_all("span", class_="pill--inline"):
+ span_classes = span.get("class", [])
+ for pill_cls, edition_name in PILL_CLASS_TO_EDITION.items():
+ if pill_cls in span_classes:
+ span.replace_with(soup.new_string(f" ({edition_name})"))
+ break
+
+
+def _process_release_note_tags(soup: "BeautifulSoup") -> None:
+ """Append edition labels from release-note__tags divs to their preceding heading.
+
+ Release notes use a
{% endblock %}
diff --git a/theme/partials/header.html b/theme/partials/header.html
index ba5ca0931d..80a4345297 100644
--- a/theme/partials/header.html
+++ b/theme/partials/header.html
@@ -20,10 +20,5 @@
{% include ".icons/material/magnify.svg" %}
{% include "partials/search.html" %}
- {% if config.repo_url %}
-
- {% include "partials/source.html" %}
-
- {% endif %}
diff --git a/theme/partials/source.html b/theme/partials/source.html
deleted file mode 100644
index 76e885dc14..0000000000
--- a/theme/partials/source.html
+++ /dev/null
@@ -1,7 +0,0 @@
-{% import "partials/language.html" as lang with context %}
-
-
- View on GitHub
-
diff --git a/update_llmstxt_config.py b/update_llmstxt_config.py
new file mode 100644
index 0000000000..8b6d35d4b3
--- /dev/null
+++ b/update_llmstxt_config.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+"""
+Update the llmstxt plugin configuration in plugins.yml based on mkdocs.yml nav structure.
+This script converts the mkdocs navigation into a format suitable for the llmstxt plugin.
+
+Files can be excluded from the llmstxt output by adding the following to their YAML frontmatter:
+
+ exclude_from_llmstxt: true
+
+Files without this property are included by default.
+"""
+
+import re
+import yaml
+from pathlib import Path
+
+
+def read_frontmatter(file_path):
+ """
+ Parse YAML frontmatter from a markdown file.
+ Returns a dict of frontmatter values, or an empty dict if none is found.
+ Handles files that begin with HTML comments before the frontmatter block.
+ """
+ try:
+ content = Path(file_path).read_text(encoding='utf-8')
+ except (OSError, UnicodeDecodeError):
+ return {}
+
+ # Strip leading HTML comments (e.g. )
+ content = re.sub(r'\A(\s*\s*)+', '', content, flags=re.DOTALL)
+
+ if not content.startswith('---'):
+ return {}
+
+ end = content.find('\n---', 3)
+ if end == -1:
+ return {}
+
+ try:
+ return yaml.safe_load(content[3:end]) or {}
+ except yaml.YAMLError:
+ return {}
+
+
+def is_excluded(file_path, docs_dir):
+ """
+ Return True if the file has 'exclude_from_llmstxt: true' in its frontmatter.
+ file_path is relative to docs_dir (as written in mkdocs nav).
+ """
+ full_path = Path(docs_dir) / file_path
+ fm = read_frontmatter(full_path)
+ return fm.get('exclude_from_llmstxt', False) is True
+
+
+def convert_nav_to_llmstxt_sections(nav_list, docs_dir):
+ """
+ Convert mkdocs nav list to llmstxt sections format.
+ Returns a dict mapping top-level section names to flat lists of file paths.
+ Files with 'exclude_from_llmstxt: true' in their frontmatter are skipped.
+ Sections that contain no remaining files are omitted.
+ """
+ sections = {}
+
+ def extract_files(item):
+ """Recursively extract included file paths from a nav item."""
+ files = []
+ if isinstance(item, str):
+ if not item.endswith('.html') and not is_excluded(item, docs_dir):
+ files.append(item)
+ elif isinstance(item, list):
+ for subitem in item:
+ files.extend(extract_files(subitem))
+ elif isinstance(item, dict):
+ for value in item.values():
+ if isinstance(value, str):
+ if not value.endswith('.html') and not is_excluded(value, docs_dir):
+ files.append(value)
+ elif isinstance(value, list):
+ for subitem in value:
+ files.extend(extract_files(subitem))
+ return files
+
+ for item in nav_list:
+ if isinstance(item, dict):
+ for section_name, section_content in item.items():
+ files = extract_files({section_name: section_content})
+ if files:
+ sections[section_name] = files
+ elif isinstance(item, str):
+ if not item.endswith('.html') and not is_excluded(item, docs_dir):
+ sections.setdefault('Ibexa Developer Documentation', []).append(item)
+
+ return sections
+
+
+def update_plugins_yml(plugins_path, mkdocs_path):
+ """
+ Update the llmstxt plugin configuration in plugins.yml based on mkdocs.yml nav.
+ """
+ with open(plugins_path, 'r') as f:
+ plugins_data = yaml.safe_load(f)
+
+ with open(mkdocs_path, 'r') as f:
+ mkdocs_data = yaml.safe_load(f)
+
+ # docs/ directory is resolved relative to mkdocs.yml location
+ docs_dir = Path(mkdocs_path).parent / mkdocs_data.get('docs_dir', 'docs')
+
+ nav = mkdocs_data.get('nav', [])
+ new_sections = convert_nav_to_llmstxt_sections(nav, docs_dir)
+
+ plugins_list = plugins_data.get('plugins', [])
+ for plugin in plugins_list:
+ if isinstance(plugin, dict) and 'llmstxt' in plugin:
+ plugin['llmstxt']['sections'] = new_sections
+ print(f"✓ Updated llmstxt plugin configuration")
+ print(f" Total sections: {len(new_sections)}")
+ break
+ else:
+ print("✗ llmstxt plugin not found in plugins.yml")
+ return False
+
+ with open(plugins_path, 'w') as f:
+ yaml.dump(plugins_data, f, default_flow_style=False, sort_keys=False,
+ allow_unicode=True, width=120)
+
+ print(f"✓ Updated {plugins_path}")
+ return True
+
+
+if __name__ == '__main__':
+ script_dir = Path(__file__).parent
+ plugins_path = script_dir / 'plugins.yml'
+ mkdocs_path = script_dir / 'mkdocs.yml'
+
+ if not plugins_path.exists():
+ print(f"✗ plugins.yml not found at {plugins_path}")
+ exit(1)
+
+ if not mkdocs_path.exists():
+ print(f"✗ mkdocs.yml not found at {mkdocs_path}")
+ exit(1)
+
+ print("Updating llmstxt configuration...")
+ print(f"Reading from: {mkdocs_path}")
+ print(f"Updating: {plugins_path}")
+ print()
+
+ success = update_plugins_yml(plugins_path, mkdocs_path)
+ exit(0 if success else 1)