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
    /
  1. so markdownify outputs 1. for all items. + + When a list is interrupted by admonitions or images, MkDocs/markdownify + may produce
      fragments. Stripping these means our + post-processing renumberer in hooks.py can assign correct sequential numbers. + """ + for ol in soup.find_all("ol"): + ol.attrs.pop("start", None) + for li in ol.find_all("li", recursive=False): + li.attrs.pop("value", None) + + +# --------------------------------------------------------------------------- +# Tables +# --------------------------------------------------------------------------- + +def _process_tables(soup: "BeautifulSoup") -> None: + """Replace ✔ (U+2714) with 'Yes' in table cells for readability.""" + CHECK = "\u2714" + for cell in soup.find_all(["td", "th"]): + if CHECK in cell.get_text(): + for node in cell.children: + if hasattr(node, "replace_with") and isinstance(node, NavigableString) and CHECK in node: + node.replace_with(NavigableString(node.replace(CHECK, "Yes"))) + + +# --------------------------------------------------------------------------- +# Tabbed sets +# --------------------------------------------------------------------------- + +def _process_tabbed_sets(soup: "BeautifulSoup") -> None: + """Prepend each tab label as bold text before its content block.""" + for tabbed_set in soup.find_all("div", class_="tabbed-set"): + labels_div = tabbed_set.find("div", class_="tabbed-labels") + content_div = tabbed_set.find("div", class_="tabbed-content") + + if not labels_div or not content_div: + tabbed_set.unwrap() + continue + + labels = [label.get_text(strip=True) for label in labels_div.find_all("label")] + blocks = content_div.find_all("div", class_="tabbed-block", recursive=False) + + wrapper = soup.new_tag("div") + for i, block in enumerate(blocks): + if i < len(labels): + label_tag = soup.new_tag("p") + strong = soup.new_tag("strong") + strong.string = labels[i] + label_tag.append(strong) + wrapper.append(label_tag) + for child in list(block.children): + wrapper.append(child.extract()) + + tabbed_set.replace_with(wrapper) + + +# --------------------------------------------------------------------------- +# Autoclean equivalent (mirrors mkdocs-llmstxt autoclean, minus tabbed-labels) +# --------------------------------------------------------------------------- + +def _autoclean(soup: "BeautifulSoup") -> None: + """Replicate the plugin's autoclean so we can run it after tab processing.""" + from bs4 import BeautifulSoup as Soup, NavigableString + + def _should_remove(tag) -> bool: + if tag.name == "img": + alt = (tag.get("alt") or "").strip() + if alt: + em = Soup(f"[Image: {alt}]", "html.parser") + tag.replace_with(em) + else: + tag.decompose() + return False + if tag.name == "svg": + return True + if tag.name == "a" and tag.find("img"): + return True + classes = tag.get("class") or () + if tag.name == "a" and "headerlink" in classes: + return True + if "twemoji" in classes: + return True + # tabbed-labels are already consumed by _process_tabbed_sets, but + # handle any stragglers defensively. + if "tabbed-labels" in classes: + return True + return False + + for element in soup.find_all(_should_remove): + element.decompose() + + for element in soup.find_all("autoref"): + element.replace_with(NavigableString(element.get_text())) + + # Insert ", " between adjacent 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
      block after each

      + containing empty
      elements rendered via CSS. + This converts them to a readable parenthetical on the heading, e.g.: + ## Google Gemini connector v5.0.7 (Headless, Experience, LTS Update, New feature) + """ + from bs4 import NavigableString as NS + + for tags_div in soup.find_all("div", class_="release-note__tags"): + editions = [] + for pill_div in tags_div.find_all("div"): + classes = pill_div.get("class", []) + for pill_cls, name in PILL_CLASS_TO_EDITION.items(): + if pill_cls in classes: + editions.append(name) + break + + heading = tags_div.find_previous_sibling(["h1", "h2", "h3", "h4"]) + if heading and editions: + # Insert before the permalink anchor so it's part of the heading text + anchor = heading.find("a", class_="headerlink") + label = NS(f" ({', '.join(editions)})") + if anchor: + anchor.insert_before(label) + else: + heading.append(label) + + tags_div.decompose() + + +def _process_info_tiles(soup: "BeautifulSoup") -> None: + """Simplify info-tile links to a single clean link text. + + Info tiles have a 'Details' label div and a separate content div. + After SVG removal, markdownify produces broken multi-line link text. + Replace the whole content with just the meaningful text. + """ + for tile in soup.find_all("a", class_="info-tile"): + # The label div ("Details") can be discarded + label = tile.find("div", class_="info-tile__details") + if label: + label.decompose() + + # Flatten remaining content to plain text + text = tile.get_text(separator=" ", strip=True) + tile.clear() + tile.append(soup.new_string(text)) + + + + """Replace ✔ checkmark characters in table cells with 'Yes'.""" + for cell in soup.find_all(["td", "th"]): + if cell.get_text(strip=True) == "\u2714": + cell.clear() + cell.append(soup.new_string("Yes")) + + +# --------------------------------------------------------------------------- +# Admonitions +# --------------------------------------------------------------------------- + +def _process_admonitions(soup: "BeautifulSoup") -> None: + """Convert admonition divs to blockquotes with a bold 'Type: Title' heading. + + Input:
      +

      Recommended versions

      +

      Body text...

      +
      + + Output:
      +

      Caution: Recommended versions

      +

      Body text...

      +
      + """ + for admonition in soup.find_all("div", class_="admonition"): + classes = admonition.get("class", []) + admonition_type = next((c for c in classes if c != "admonition"), None) + + title_elem = admonition.find("p", class_="admonition-title") + title_text = title_elem.get_text(strip=True) if title_elem else "" + + blockquote = soup.new_tag("blockquote") + + # Bold title paragraph + title_p = soup.new_tag("p") + strong = soup.new_tag("strong") + prefix = f"{admonition_type.capitalize()}: " if admonition_type else "" + strong.string = f"{prefix}{title_text}" + title_p.append(strong) + blockquote.append(title_p) + + # Body: all children except the title + for child in list(admonition.children): + if child == title_elem: + continue + blockquote.append(child.extract()) + + admonition.replace_with(blockquote) + + + + +def _process_release_notes_filters(soup: "BeautifulSoup") -> None: + """Remove interactive release-notes filter UI elements.""" + for container in soup.find_all("div", class_="release-notes-filters"): + container.decompose() + + +# --------------------------------------------------------------------------- +# Card macros +# --------------------------------------------------------------------------- + +def _process_cards(soup: "BeautifulSoup") -> None: + """Convert card macro HTML structures into markdown-friendly lists with links.""" + for cards_div in soup.find_all("div", class_=lambda c: c and c.startswith("cards ")): + card_wrappers = cards_div.find_all("div", class_="card-wrapper") + + if not card_wrappers: + continue + + ul = soup.new_tag("ul") + + for card_wrapper in card_wrappers: + link = card_wrapper.find("a", class_="card") + if not link: + continue + + href = link.get("href", "") + if href.startswith("//"): + href = "https:" + href + + title_elem = link.find("p", class_="title") + description_elem = link.find("p", class_="description") + + if not title_elem: + continue + + title = title_elem.get_text(strip=True) + description = description_elem.get_text(strip=True) if description_elem else "" + + li = soup.new_tag("li") + link_tag = soup.new_tag("a", href=href) + link_tag.string = title + li.append(link_tag) + + if description: + li.append(soup.new_string(": ")) + li.append(soup.new_string(description)) + + ul.append(li) + + cards_div.replace_with(ul) diff --git a/mkdocs.yml b/mkdocs.yml index a2082cc594..3f5f242903 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: @@ -1092,3 +1093,6 @@ markdown_extensions: custom_checkbox: true - pymdownx.tilde - pymdownx.details + +hooks: + - hooks.py diff --git a/plugins.yml b/plugins.yml index 77c3fd4879..6ffe173de5 100644 --- a/plugins.yml +++ b/plugins.yml @@ -586,3 +586,13 @@ plugins: 'ai_actions/install_ai_actions.md': 'ai_actions/configure_ai_actions.md' 'discounts/install_discounts.md': 'discounts/configure_discounts.md' 'content_management/collaborative_editing/install_collaborative_editing.md': 'content_management/collaborative_editing/configure_collaborative_editing.md' + + - llmstxt: + preprocess: llmstxt_preprocess.py + + autoclean: false + full_output: llms-full.txt + sections: + # Built automatically when running MkDocs build based on MkDocs nav configuration, don't change + Ibexa DXP developer documentation: + - index.md diff --git a/requirements.txt b/requirements.txt index c03e3d1a35..688d9446a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ mkdocs-macros-plugin==1.3.7 mkdocs-redirects==1.2.2 mkdocs-autolinks-plugin==0.7.1 Jinja2==3.1.6 +mkdocs-llmstxt diff --git a/theme/assets/page-actions.js b/theme/assets/page-actions.js new file mode 100644 index 0000000000..ea34914d80 --- /dev/null +++ b/theme/assets/page-actions.js @@ -0,0 +1,55 @@ +/** + * Page Actions JavaScript + * Handles functionality for page action buttons (Copy as Markdown, View as Markdown, Edit on GitHub) + */ + +async function copyPageForLLM() { + const mdPath = document.querySelector('meta[name="markdown-path"]').content; + + try { + const response = await fetch(mdPath); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const markdownContent = await response.text(); + await navigator.clipboard.writeText(markdownContent); + showButtonFeedback('success', 'Copied!'); + } catch (error) { + console.error('Failed to copy content:', error); + window.open(mdPath, '_blank'); + showButtonFeedback('info', 'Opened in tab'); + } +} + +function showButtonFeedback(type, message) { + const button = document.querySelector('button[onclick="copyPageForLLM()"]'); + if (!button) return; + + const originalHTML = button.innerHTML; + button.innerHTML = `${message}`; + + if (type === 'success') { + button.classList.add('page-action-btn--success'); + } else if (type === 'info') { + button.classList.add('page-action-btn--info'); + } + + setTimeout(() => { + button.innerHTML = originalHTML; + button.classList.remove('page-action-btn--success', 'page-action-btn--info'); + }, 2000); +} + +document.addEventListener('DOMContentLoaded', function() { + const pageActions = document.getElementById('page-actions'); + const firstH1 = document.querySelector('.bootstrap-iso h1, h1'); + + if (pageActions && firstH1) { + // If h1 is inside a special header container (e.g. release-notes-header), + // insert page-actions after that container so it doesn't disrupt the header layout. + const headerContainer = firstH1.closest('.release-notes-header'); + const anchor = headerContainer || firstH1; + anchor.insertAdjacentElement('afterend', pageActions); + pageActions.classList.add('page-actions--visible'); + } +}); diff --git a/theme/main.html b/theme/main.html index 66ce7dc87d..1688f3cfe0 100644 --- a/theme/main.html +++ b/theme/main.html @@ -13,6 +13,21 @@ + {% if config.repo_url and page.edit_url %} + + + {% endif %} + + + + + {% if page and not page.meta.exclude_from_llmstxt %} + {% set md_href = page.file.dest_uri | replace('.html', '.md') %} + + {% endif %} + + + {% endblock %} {% block site_nav %} {% if nav %} @@ -75,7 +90,36 @@
      {% endif %} {% include "partials/eol_warning.html" %} - {{ page.content }} + + + {% include "partials/tags.html" %}

      {% 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 %} - -
      - {% include ".icons/fontawesome/brands/github-alt.svg" %} -
      - 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)