diff --git a/docs/ai-docs.md b/docs/ai-docs.md index f81ce62..bfed83f 100644 --- a/docs/ai-docs.md +++ b/docs/ai-docs.md @@ -33,6 +33,9 @@ plugins: | `llms_config` | `string` | `llms_config.json` | Path to the LLM config file, relative to `mkdocs.yml`. | | `ai_resources_page` | `bool` | `true` | Generate the AI resources page from `ai-resources.md`. Set to `false` to disable. | | `ai_page_actions` | `bool` | `true` | Inject the per-page AI actions widget next to each H1. Set to `false` to disable. | +| `ai_page_actions_anchor` | `string` | `""` | CSS class name of the element(s) to append the widget into instead of wrapping the H1. When set, the default H1-wrapping behavior is replaced — see [Custom anchor](#custom-anchor). | +| `ai_page_actions_style` | `string` | `"split"` | Widget layout style. `"split"` renders a copy button left of the dropdown arrow; `"dropdown"` renders a single labelled button with all actions inside — see [Widget style](#widget-style). | +| `ai_page_actions_dropdown_label` | `string` | `"Markdown for LLMs"` | Trigger button label when `ai_page_actions_style` is `"dropdown"`. | | `enabled` | `bool` | `true` | Disable the entire plugin (all features). Supports `!ENV` for environment-based toggling. | The `llms_config` file controls content filtering, category definitions, and output paths. See [Resolve Markdown](resolve-md.md#-configuration) for a full breakdown of the `llms_config.json` schema. @@ -52,10 +55,56 @@ Always runs when the plugin is enabled. Processes every documentation markdown f ### AI page actions (`ai_page_actions`) -Injects a split-button dropdown widget next to each page's H1 heading at build time. The widget lets readers copy, download, or open the page's resolved markdown in an LLM tool. Pages listed in `llms_config.json` exclusions, dot-directories, and pages with `hide_ai_actions: true` in their front matter are automatically skipped. +Injects an AI actions widget (split-button by default, or plain dropdown via `ai_page_actions_style`) next to each page's H1 heading at build time. The widget lets readers copy, download, or open the page's resolved markdown in an LLM tool. Pages listed in `llms_config.json` exclusions, dot-directories, and pages with `hide_ai_actions: true` in their front matter are automatically skipped. See [AI Page Actions](ai-page-actions.md) for details on exclusion rules, toggle page handling, and styling. +#### Widget style + +The widget supports two layout styles, controlled by `ai_page_actions_style`. + +**`split`** (default) — a copy button sits to the left of a chevron trigger that opens the dropdown: + +```yaml +plugins: + - ai_docs: + ai_page_actions_style: split # default, same as omitting the option +``` + +**`dropdown`** — a single labelled button opens a menu that contains all actions, including copy. No separate copy button is rendered outside the menu: + +```yaml +plugins: + - ai_docs: + ai_page_actions_style: dropdown + ai_page_actions_dropdown_label: Markdown for LLMs # default +``` + +The container gets the additional CSS class `ai-file-actions-container--dropdown` so you can style the two modes independently. Resources table widgets always carry `ai-file-actions-container--table`. See [Styling](ai-page-actions.md#styling) for the full class reference and CSS examples. + +#### Custom anchor + +By default, the widget is placed by wrapping the H1 in a `
` flex container. If your theme or custom layout already has a dedicated slot for page-level actions, you can redirect the widget there instead: + +```yaml +plugins: + - ai_docs: + ai_page_actions_anchor: my-page-actions +``` + +The plugin then finds every element that has the class `my-page-actions` within `.md-content` and appends the widget into it, leaving the H1 untouched. If no matching element is found on a given page, the page is left unchanged and a debug message is logged. + +#### Toggle pages + +When using `ai_page_actions_anchor` alongside the [`page_toggle` plugin](page-toggle.md), your template must render one anchor element per variant inside the toggle container, each carrying the matching `data-variant` attribute. For example: + +```html +
+
+``` + +A Jinja macro is a convenient way to do this — iterate over your variants and emit the element with the correct `data-variant` for each one. + ### AI resources page (`ai_resources_page`) Automatically generates the content for a page named `ai-resources.md`, replacing it with a table listing all available LLM artifact files (global indexes and per-category bundles) with copy, view, and download actions. diff --git a/docs/ai-page-actions.md b/docs/ai-page-actions.md index 1ba6fdc..512b859 100644 --- a/docs/ai-page-actions.md +++ b/docs/ai-page-actions.md @@ -50,6 +50,32 @@ The plugin loads `llms_config.json` from the project root (the directory contain The widget uses the same CSS classes as the table widget (`ai-file-actions.css`). The H1 wrapper layout is controlled by `.h1-ai-actions-wrapper`, which includes mobile responsive styles that stack the H1 and widget vertically on small screens. +### CSS classes + +Every widget shares the base container class. Modifier classes are added automatically based on context, giving you clean CSS selectors for each placement: + +| Class | Present on | +| :--- | :--- | +| `.ai-file-actions-container` | Every widget (base class) | +| `.ai-file-actions-container--dropdown` | Per-page widget rendered in dropdown style (applied by `ai_docs` when `ai_page_actions_style: dropdown`) | +| `.ai-file-actions-container--table` | Widgets inside the AI resources page tables | + +### Targeting each widget independently + +```css +/* All widgets */ +.ai-file-actions-container { } + +/* Per-page split style only (default) */ +.ai-file-actions-container:not(.ai-file-actions-container--dropdown):not(.ai-file-actions-container--table) { } + +/* Per-page dropdown style only (when used via ai_docs with ai_page_actions_style: dropdown) */ +.ai-file-actions-container--dropdown { } + +/* Resources table widgets only */ +.ai-file-actions-container--table { } +``` + ## Client-Side Behavior The widget relies on `ai-file-actions.js` for all client-side interactions (copy, download, dropdown toggle, keyboard navigation, analytics). No additional JavaScript is needed. diff --git a/helper_lib/ai_file_utils/ai_file_utils.py b/helper_lib/ai_file_utils/ai_file_utils.py index df7c5d0..65d8248 100644 --- a/helper_lib/ai_file_utils/ai_file_utils.py +++ b/helper_lib/ai_file_utils/ai_file_utils.py @@ -359,14 +359,25 @@ def generate_dropdown_html( site_url: str = "", label_replace: dict[str, str] | None = None, content: str = "", + style: str = "split", + dropdown_label: str = "Markdown for LLMs", + extra_classes: str = "", ) -> str: """ - Generate the HTML for the AI file actions split-button. + Generate the HTML for the AI file actions widget. - The action marked ``primary: true`` in the JSON renders - as the left-side button; all other actions render as - dropdown items. The primary action is automatically - excluded from the dropdown. + Two styles are supported: + + ``"split"`` (default) + The action marked ``primary: true`` in the JSON renders as a + left-side button; all other actions render as dropdown items. + The primary action is automatically excluded from the dropdown. + + ``"dropdown"`` + A single trigger button labelled ``dropdown_label`` (default + ``"Markdown for LLMs"``) opens a menu that contains *all* + actions, including the primary one. No separate copy button + is rendered. Args: url: The relative URL of the file to act upon. @@ -375,12 +386,18 @@ def generate_dropdown_html( from the dropdown. primary_label: Optional label override for the primary button (e.g., "Copy page" vs default "Copy file"). + Only used in ``"split"`` style. site_url: The base site URL (e.g., "https://docs.polkadot.com/"). When provided, ``page_url`` passed to prompt templates will be the fully-qualified URL. label_replace: Optional dict of string replacements to apply to dropdown item labels (e.g., ``{"file": "page"}``). content: Optional page content for prompt template interpolation. + style: Widget style — ``"split"`` or ``"dropdown"``. + dropdown_label: Trigger button label used in ``"dropdown"`` style. + extra_classes: Additional CSS class(es) to append to the container + div (e.g., ``"ai-file-actions-container--table"``). + Multiple classes can be space-separated. Returns: The HTML string for the component. @@ -396,10 +413,83 @@ def generate_dropdown_html( page_url=url, filename=filename, content=content, prompt_page_url=full_url ) - # Separate primary action from dropdown actions + exclude_set = set(exclude) if exclude else set() + + # Build container class string — style modifier is auto-added, then + # any caller-supplied extra_classes are appended on top. + container_classes = "ai-file-actions-container" + if style == "dropdown": + container_classes += " ai-file-actions-container--dropdown" + if extra_classes: + container_classes += f" {extra_classes}" + + chevron = ( + '' + ) + + # ------------------------------------------------------------------ # + # "dropdown" style — single trigger button, all actions in the menu # + # ------------------------------------------------------------------ # + if style == "dropdown": + menu_items = "" + for action in actions: + if action.get("id") in exclude_set: + continue + if label_replace and "label" in action: + for old, new in label_replace.items(): + action["label"] = action["label"].replace(old, new) + menu_items += self._render_action_item(action, url) + + safe_url = html.escape(url, quote=True) + escaped_label = html.escape(dropdown_label, quote=True) + markdown_icon = ( + '' + ) + trigger_btn = ( + '" + ) + dropdown_menu = ( + '" + ) + return ( + f'
' + f"{trigger_btn}" + f"{dropdown_menu}" + "
" + ) + + # ------------------------------------------------------------------ # + # "split" style (default) — primary copy button + chevron trigger # + # ------------------------------------------------------------------ # primary_action = None dropdown_actions = [] - exclude_set = set(exclude) if exclude else set() for action in actions: if action.get("primary"): @@ -416,15 +506,6 @@ def generate_dropdown_html( ) # Dropdown trigger (right side of split button) - chevron = ( - '' - ) dropdown_btn = ( '' ) - # Content panels + # Content panels — inject per-variant tests placeholder before + # "Where to Go Next" (or at end) so the template can replace it + # with the rendered test block for this variant. + panel_html = self._inject_tests_placeholder(data["html"], variant) toc_html_attr = escape(data["toc_html"], quote=True) content_html.append( f'
' - f'{data["html"]}' + f'{panel_html}' f"
" ) @@ -234,12 +254,30 @@ def render_toggle_page(self, group: str) -> str: {''.join(buttons_html)}
+
{''.join(content_html)}
""" + # ------------------------------------------------------------ + # Inject per-variant tests placeholder into panel HTML + # ------------------------------------------------------------ + def _inject_tests_placeholder(self, html: str, variant: str) -> str: + """ + Insert immediately before the + 'Where to Go Next' h2 section, or append it at the end if that + section is absent. The template replaces each placeholder with + the rendered test block for the corresponding variant. + """ + placeholder = f"" + marker = '

= 0: + return html[:idx] + placeholder + html[idx:] + return html + placeholder + # ------------------------------------------------------------ # Render page TOC in MkDocs sidebar format # ------------------------------------------------------------ diff --git a/tests/ai_docs/test_ai_docs.py b/tests/ai_docs/test_ai_docs.py index e01b0d7..474fb15 100644 --- a/tests/ai_docs/test_ai_docs.py +++ b/tests/ai_docs/test_ai_docs.py @@ -492,39 +492,38 @@ class TestAiResourcesPageMarkdown: """Tests for on_page_markdown output: static prose and placeholder divs.""" def test_emits_aggregate_placeholder_div(self, tmp_path): - """on_page_markdown should include the aggregate table placeholder.""" + """on_page_markdown should include the aggregate table HTML comment placeholder.""" plugin = _make_plugin() config = _make_mkdocs_config(tmp_path) page = _make_page(src_path="ai-resources.md") result = plugin.on_page_markdown("", page=page, config=config, files=[]) - assert '
' in result + assert '' in result def test_emits_category_placeholder_divs(self, tmp_path): - """on_page_markdown should include a placeholder div for each configured category.""" + """on_page_markdown should include an HTML comment placeholder for each configured category.""" plugin = _make_plugin() config = _make_mkdocs_config(tmp_path) page = _make_page(src_path="ai-resources.md") result = plugin.on_page_markdown("", page=page, config=config, files=[]) - assert '
' in result + assert '' in result def test_emits_static_prose(self, tmp_path): - """on_page_markdown should include the overview heading and how-to section.""" + """on_page_markdown should include the overview heading and access section.""" plugin = _make_plugin() config = _make_mkdocs_config(tmp_path) page = _make_page(src_path="ai-resources.md") result = plugin.on_page_markdown("", page=page, config=config, files=[]) assert "# AI Resources" in result - assert "## How to Use These Files" in result assert "## Access LLM Files" in result def test_emits_category_headings_for_toc(self, tmp_path): - """on_page_markdown should emit ## Categories and per-category ### headings for TOC.""" + """on_page_markdown should emit ### Category Files and per-category #### headings for TOC.""" plugin = _make_plugin() config = _make_mkdocs_config(tmp_path) page = _make_page(src_path="ai-resources.md") result = plugin.on_page_markdown("", page=page, config=config, files=[]) - assert "## Categories" in result - assert "### Basics" in result + assert "### Category Files" in result + assert "#### Basics" in result def test_no_table_rows_in_markdown(self, tmp_path): """on_page_markdown must not contain table rows — those are injected in on_post_build.""" @@ -597,7 +596,7 @@ def test_mcp_section_present_when_configured(self, tmp_path): assert "VS Code" in result assert "Claude Code CLI" in result assert "Codex CLI" in result - assert "Claude Desktop" in result + assert ":simple-claude: Claude" in result # Deeplinks should be generated assert "cursor://anysphere.cursor-deeplink" in result assert "vscode:mcp/install?" in result @@ -739,8 +738,8 @@ def _write_html(self, path, content): def _base_html(self): return ( '' - '
' - '
' + '' + '' '' ) @@ -757,8 +756,8 @@ def test_replaces_placeholder(self, tmp_path): plugin._patch_ai_resources_page(site_dir, config) result = html_path.read_text(encoding="utf-8") - assert '
' not in result - assert '
' not in result + assert '' not in result + assert '' not in result assert "" in result assert "Token Estimate" in result @@ -935,3 +934,277 @@ def test_page_count_zero_for_unmatched_category(self, tmp_path): content = (ai_root / "categories" / "basics-light.md").read_text(encoding="utf-8") fm = yaml.safe_load(content.split("---")[1]) assert fm["page_count"] == 0 + + +# =========================================================================== +# ai_page_actions_anchor +# =========================================================================== + +class TestAiPageActionsAnchor: + """Tests for the ai_page_actions_anchor config option.""" + + def _make_plugin(self, anchor_class=""): + plugin = AIDocsPlugin() + plugin.config = { + "llms_config": "llms_config.json", + "ai_resources_page": True, + "ai_page_actions": True, + "ai_page_actions_anchor": anchor_class, + } + plugin._config_loaded = True + return plugin + + def _make_page(self, url="guide/", src_path="guide.md"): + page = MagicMock() + page.is_homepage = False + page.file.src_path = src_path + page.meta = {} + page.url = url + return page + + def test_anchor_mode_injects_into_target_element(self): + """Widget is appended into the element with the configured class.""" + plugin = self._make_plugin(anchor_class="page-actions-slot") + page = self._make_page() + output = ( + '
' + '

Guide

' + '
' + "
" + ) + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + soup = BeautifulSoup(result, "html.parser") + slot = soup.select_one(".page-actions-slot") + assert slot is not None + assert slot.find(attrs={"data-url": True}) is not None + + def test_anchor_mode_does_not_wrap_h1(self): + """When anchor class is set, the H1 is not wrapped in h1-ai-actions-wrapper.""" + plugin = self._make_plugin(anchor_class="page-actions-slot") + page = self._make_page() + output = ( + '
' + '

Guide

' + '
' + "
" + ) + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + assert "h1-ai-actions-wrapper" not in result + + def test_anchor_mode_injects_into_all_matching_elements(self): + """Widget is appended into every element with the anchor class.""" + plugin = self._make_plugin(anchor_class="action-slot") + page = self._make_page() + output = ( + '
' + '
' + '
' + "
" + ) + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + soup = BeautifulSoup(result, "html.parser") + slots = soup.select(".action-slot") + assert len(slots) == 2 + for slot in slots: + assert slot.find(attrs={"data-url": True}) is not None + + def test_anchor_mode_no_match_returns_output_unchanged(self): + """When the anchor class is not found, the page is returned unchanged.""" + plugin = self._make_plugin(anchor_class="nonexistent-slot") + page = self._make_page() + output = '

Guide

' + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + assert result == output + + def test_anchor_mode_uses_page_route_for_md_path(self): + """The widget data-url is derived from the page route, not a toggle variant.""" + plugin = self._make_plugin(anchor_class="slot") + page = self._make_page(url="api/reference/", src_path="api/reference.md") + output = ( + '
' + '
' + "
" + ) + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + soup = BeautifulSoup(result, "html.parser") + data_url = soup.find(attrs={"data-url": True})["data-url"] + assert data_url == "/api/reference.md" + + def test_empty_anchor_falls_back_to_h1_wrapping(self): + """When ai_page_actions_anchor is empty, the default H1-wrapping behavior runs.""" + plugin = self._make_plugin(anchor_class="") + page = self._make_page() + output = '

Guide

Content

' + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + assert "h1-ai-actions-wrapper" in result + + # --- Toggle page + anchor_class tests --- + + def test_toggle_anchor_injects_widget_per_variant_with_data_filename(self): + """Each variant's anchor element gets a widget whose data-url matches data-filename.""" + plugin = self._make_plugin(anchor_class="actions-slot") + page = self._make_page(url="runtime/", src_path="runtime.md") + output = ( + '
' + '
' + '' + '' + '
' + '
' + "
" + "
" + ) + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + soup = BeautifulSoup(result, "html.parser") + + stable_slot = soup.select_one('.actions-slot[data-variant="stable"]') + latest_slot = soup.select_one('.actions-slot[data-variant="latest"]') + + assert stable_slot is not None + assert latest_slot is not None + + stable_widget = stable_slot.find(attrs={"data-url": True}) + latest_widget = latest_slot.find(attrs={"data-url": True}) + + assert stable_widget is not None, "stable slot should have a widget" + assert latest_widget is not None, "latest slot should have a widget" + assert stable_widget["data-url"] == "/runtime-stable.md" + assert latest_widget["data-url"] == "/runtime-latest.md" + + def test_toggle_anchor_falls_back_to_route_when_no_data_filename(self): + """When data-filename is absent, the widget data-url falls back to the page route.""" + plugin = self._make_plugin(anchor_class="actions-slot") + page = self._make_page(url="guide/", src_path="guide.md") + output = ( + '
' + '
' + '' + '
' + "
" + "
" + ) + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + soup = BeautifulSoup(result, "html.parser") + widget = soup.select_one('.actions-slot[data-variant="v1"]').find(attrs={"data-url": True}) + assert widget is not None + assert widget["data-url"] == "/guide.md" + + def test_toggle_anchor_no_match_skips_injection(self): + """When the anchor class is not found for a variant, no widget is injected.""" + plugin = self._make_plugin(anchor_class="actions-slot") + page = self._make_page(url="guide/", src_path="guide.md") + output = ( + '
' + '
' + '' + # No matching .actions-slot[data-variant="v1"] present + '
' + "
" + "
" + ) + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + soup = BeautifulSoup(result, "html.parser") + assert soup.find(attrs={"data-url": True}) is None + + def test_toggle_anchor_does_not_inject_into_other_variants_slot(self): + """Each variant's widget only appears in its own anchor element, not another variant's.""" + plugin = self._make_plugin(anchor_class="slot") + page = self._make_page(url="docs/", src_path="docs.md") + output = ( + '
' + '
' + '' + '' + '
' + '
' + "
" + "
" + ) + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + soup = BeautifulSoup(result, "html.parser") + + slot_a = soup.select_one('.slot[data-variant="a"]') + slot_b = soup.select_one('.slot[data-variant="b"]') + + assert slot_a.find(attrs={"data-url": "/docs-a.md"}) is not None + assert slot_b.find(attrs={"data-url": "/docs-b.md"}) is not None + # Ensure cross-contamination didn't happen + assert slot_a.find(attrs={"data-url": "/docs-b.md"}) is None + assert slot_b.find(attrs={"data-url": "/docs-a.md"}) is None + + +# =========================================================================== +# ai_page_actions_style +# =========================================================================== + +class TestAiPageActionsStyle: + """Tests for the ai_page_actions_style and ai_page_actions_dropdown_label options.""" + + def _make_plugin(self, style="split", dropdown_label="Markdown for LLMs"): + plugin = AIDocsPlugin() + plugin.config = { + "llms_config": "llms_config.json", + "ai_resources_page": True, + "ai_page_actions": True, + "ai_page_actions_anchor": "", + "ai_page_actions_style": style, + "ai_page_actions_dropdown_label": dropdown_label, + } + plugin._config_loaded = True + return plugin + + def _make_page(self, url="guide/", src_path="guide.md"): + page = MagicMock() + page.is_homepage = False + page.file.src_path = src_path + page.meta = {} + page.url = url + return page + + def test_split_style_renders_copy_button(self): + """Split style produces the primary copy button outside the dropdown menu.""" + plugin = self._make_plugin(style="split") + page = self._make_page() + output = '

Guide

' + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + assert "ai-file-actions-copy" in result + + def test_split_style_no_dropdown_modifier(self): + """Split style does not apply the --dropdown container modifier.""" + plugin = self._make_plugin(style="split") + page = self._make_page() + output = '

Guide

' + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + assert "ai-file-actions-container--dropdown" not in result + + def test_dropdown_style_no_copy_button(self): + """Dropdown style does not render a standalone copy button.""" + plugin = self._make_plugin(style="dropdown") + page = self._make_page() + output = '

Guide

' + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + assert "ai-file-actions-copy" not in result + + def test_dropdown_style_container_modifier(self): + """Dropdown style applies the --dropdown modifier to the container.""" + plugin = self._make_plugin(style="dropdown") + page = self._make_page() + output = '

Guide

' + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + assert "ai-file-actions-container--dropdown" in result + + def test_dropdown_style_trigger_label(self): + """Dropdown style uses the configured dropdown_label on the trigger.""" + plugin = self._make_plugin(style="dropdown", dropdown_label="AI Tools") + page = self._make_page() + output = '

Guide

' + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + assert "AI Tools" in result + + def test_dropdown_style_default_label(self): + """Dropdown style uses 'Markdown for LLMs' as the default trigger label.""" + plugin = self._make_plugin(style="dropdown") + page = self._make_page() + output = '

Guide

' + result = plugin.on_post_page(output, page=page, config={"site_url": ""}) + assert "Markdown for LLMs" in result diff --git a/tests/ai_file_utils/test_ai_file_utils.py b/tests/ai_file_utils/test_ai_file_utils.py index 3200006..7c64fa1 100644 --- a/tests/ai_file_utils/test_ai_file_utils.py +++ b/tests/ai_file_utils/test_ai_file_utils.py @@ -309,16 +309,118 @@ def test_mcp_copy_code_escapes_html(self): assert "