diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d7940c9922..beab4188ca 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -65,6 +65,25 @@ jobs: exit 1 fi + python-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.13" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run pytest + run: pytest + markdownlint: runs-on: ubuntu-latest if: github.event_name == 'pull_request' diff --git a/.gitignore b/.gitignore index c0ebcf0350..f30fd66606 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ /vendor/ **/.DS_Store __pycache__/* -hooks/__pycache__/* +**/__pycache__ /site/ **/.idea/ .php-cs-fixer.cache diff --git a/docs/css/custom.css b/docs/css/custom.css index fe9b85528a..7645062503 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 2rem 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/navigation.css b/docs/css/navigation.css index a62f53f0b4..d6698cfb0e 100644 --- a/docs/css/navigation.css +++ b/docs/css/navigation.css @@ -393,7 +393,7 @@ margin-left: 0; margin-top: 0; margin-bottom: 0; - padding-bottom: 2rem; + padding-bottom: 0.5rem; } ul.breadcrumbs li.breadcrumb-item { diff --git a/docs/css/release-notes.css b/docs/css/release-notes.css index 8663f96563..a656953848 100644 --- a/docs/css/release-notes.css +++ b/docs/css/release-notes.css @@ -2,11 +2,9 @@ display: flex; justify-content: space-between; align-items: center; - border-bottom: 1px solid #DCDDDE; - padding: 8px 0; + padding: 0; } .release-notes-header h1 { - line-height: 1.5; margin: 0; } diff --git a/docs/index.md b/docs/index.md index 591499ccf1..05f88a5ecc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -82,7 +82,7 @@ Release notes
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()
+
+ # Flatten line-numbered code blocks to a plain , keeping the
+ # language-* class (emitted thanks to pygments_lang_class) on the ,
+ # where markdownify's code_language_callback looks for it.
+ for element in soup.find_all("table", attrs={"class": "highlighttable"}):
+ code_elem = element.find("code")
+ if code_elem:
+ classes = code_elem.get("class") or ()
+ language = next((c for c in classes if c.startswith("language-")), "")
+ attr = f' class="{language}"' if language else ""
+ element.replace_with(
+ Soup(f"{html_module.escape(code_elem.get_text())}", "html.parser")
+ )
+
+# ---------------------------------------------------------------------------
+# Inline edition badge spans (from snippet includes)
+# ---------------------------------------------------------------------------
+
+def _pill_edition(node) -> str:
+ """Return the edition name of an inline pill span, or '' if not one."""
+ if getattr(node, "name", None) != "span":
+ return ""
+ classes = node.get("class") or []
+ if "pill--inline" not in classes:
+ return ""
+ for pill_cls, edition_name in PILL_CLASS_TO_EDITION.items():
+ if pill_cls in classes:
+ return edition_name
+ return ""
+
+
+def _process_inline_pills(soup: Soup) -> None:
+ """Replace inline edition pill spans with readable text.
+
+ Consecutive pills (possibly separated by whitespace) are merged into a
+ single parenthetical, e.g. ' (Experience, Commerce)' instead of
+ ' (Experience) (Commerce)'.
+ """
+ for span in soup.find_all("span", class_="pill--inline"):
+ if span.parent is None: # already consumed as part of a previous run
+ continue
+ edition = _pill_edition(span)
+ if not edition:
+ continue
+
+ # Collect the run of pills that follow, skipping whitespace between them.
+ editions = [edition]
+ consumed = []
+ node = span.next_sibling
+ pending_whitespace = []
+ while node is not None:
+ if isinstance(node, NavigableString) and not node.strip():
+ pending_whitespace.append(node)
+ node = node.next_sibling
+ continue
+ next_edition = _pill_edition(node)
+ if not next_edition:
+ break
+ editions.append(next_edition)
+ consumed += pending_whitespace + [node]
+ pending_whitespace = []
+ node = node.next_sibling
+
+ for extra_node in consumed:
+ extra_node.extract()
+ span.replace_with(soup.new_string(f" ({', '.join(editions)})"))
+
+
+def _process_release_note_tags(soup: Soup) -> 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 0948754c07..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..b232392c38
--- /dev/null
+++ b/update_llmstxt_config.py
@@ -0,0 +1,153 @@
+#!/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
+
+This excludes the page from both llms.txt and llms-full.txt, skips generating its Markdown
+version, and hides the page action buttons. There is no per-file control for excluding a page
+from llms.txt only (both files are derived from the same `sections` config).
+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)