Skip to content
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Currently included:
- **[Minify](https://github.com/papermoonio/mkdocs-plugins/blob/main/docs/minify.md)**: Minify HTML, JS, and CSS files globally or by scope to optimize your site's performance.
- **[Page Toggle](https://github.com/papermoonio/mkdocs-plugins/blob/main/docs/page-toggle.md)**: Create variant pages for the same content and display them with an interactive toggle interface.
- **[Resolve Markdown](https://github.com/papermoonio/mkdocs-plugins/blob/main/docs/resolve-md.md)**: Resolve variable and code snippet placeholders and serve resolved Markdown files directly from your site's build folder.
- **[AI File Utils](https://github.com/papermoonio/mkdocs-plugins/blob/main/docs/ai-file-utils.md)**: Serves as a centralized "contract" and utility service for defining and resolving actions related to AI artifacts.

## Installation

Expand All @@ -33,6 +34,7 @@ plugins:
- page_toggle
- resolve_md:
llms_config: example_config.json
- ai_file_utils
```
## License

Expand Down
84 changes: 84 additions & 0 deletions docs/ai-file-utils.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# AI File Utils Plugin

The AI File Utils plugin serves as a centralized "contract" and utility service for defining and resolving actions related to AI artifacts. It allows you to separate the *definition* of actions (like "View Markdown", "Open in ChatGPT") from the *implementation* in the UI.

This plugin does not output files directly; instead, it provides a Python API that other plugins (like `resolve_md`) can essentially import and use to generate standardized action lists for any documentation page.

## 🔹 Usage

Enable the plugin in your `mkdocs.yml`. It requires no configuration options in the YAML itself, as it loads its schema from an internal JSON file.

```yaml
plugins:
- ai_file_utils
```

### Using in Python Code

Other plugins can access the utilities provided by this plugin. The primary API is `resolve_actions`, which takes page context and returns a list of fully resolved action objects.

```python
from plugins.ai_file_utils.plugin import AIFileUtilsPlugin

# Instantiate (or get reference to)
utils = AIFileUtilsPlugin()
# Ensure config is loaded
utils.on_config({})

# Resolve actions for a specific page context
actions = utils.resolve_actions(
page_url="https://docs.example.com/ai/pages/my-page.md",
filename="my-page.md",
content="# My Page Content..."
)
```

## 🔹 The Action Model

The core of this plugin is the **Action Model**, defined in `ai_file_actions.json`. This JSON schema defines what actions are available and how they behave.

### Schema Fields

| Field | Type | Description |
| :--- | :--- | :--- |
| `type` | `string` | The category of action. Currently supports `link` (navigation) and `clipboard` (copy text). |
| `id` | `string` | Unique identifier (e.g., `view-markdown`, `open-chat-gpt`). |
| `label` | `string` | The human-readable text displayed in the UI. |
| `analyticsKey` | `string` | A standardized key for tracking usage events. |
| `href` | `string` | (Link only) The destination URL. Supports interpolation. |
| `download` | `string` | (Link only) If present, triggers a file download with this filename. |
| `clipboardContent` | `string` | (Clipboard only) The text to be copied. |
| `promptTemplate` | `string` | (LLM only) A template used to generate the `{{ prompt }}` variable. |

### Interpolation Variables

The plugin supports dynamic injection of context using `{{ variable }}` syntax.

| Variable | Description |
| :--- | :--- |
| `{{ page_url }}` | The full URL to the resolved AI artifact (the markdown file). |
| `{{ filename }}` | The filename of the markdown file (e.g., `basics.md`). |
| `{{ content }}` | The full text content of the markdown file. |
| `{{ prompt }}` | A special variable generated by processing the `promptTemplate` and URL-encoding the result. |

## 🔹 Adding New Actions

To add a new action (e.g., "Open in Gemini"), you modify `plugins/ai_file_utils/ai_file_actions.json`. You do not need to write new Python code.

### Example: Adding a new LLM

```json
{
"type": "link",
"id": "open-gemini",
"label": "Open in Gemini",
"href": "https://gemini.google.com/app?q={{ prompt }}",
"promptTemplate": "Read {{ page_url }} and explain it to me.",
"analyticsKey": "open_page_markdown_gemini"
}
```

This configuration will automatically:
1. Resolve `{{ page_url }}` in the prompt template.
2. URL-encode the result into `{{ prompt }}`.
3. Inject it into the `href`.
Empty file.
42 changes: 42 additions & 0 deletions plugins/ai_file_utils/ai_file_actions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"actions": [
{
"type": "link",
"id": "view-markdown",
"label": "View Markdown",
"href": "{{ page_url }}",
"analyticsKey": "view_page_markdown"
},
{
"type": "link",
"id": "download-markdown",
"label": "Download Markdown",
"href": "{{ page_url }}",
"download": "{{ filename }}",
"analyticsKey": "download_page_markdown"
},
{
"type": "clipboard",
"id": "copy-markdown",
"label": "Copy Markdown",
"clipboardContent": "{{ content }}",
"analyticsKey": "copy_page_markdown"
},
{
"type": "link",
"id": "open-chat-gpt",
"label": "Open in ChatGPT",
"href": "https://chatgpt.com/?q={{ prompt }}",
"promptTemplate": "Analyze the documentation at https://r.jina.ai/{{ page_url }}. Focus on the technical implementation details and code examples. I want to ask you questions about implementing these protocols.",
"analyticsKey": "open_page_markdown_chatgpt"
},
{
"type": "link",
"id": "open-claude",
"label": "Open in Claude",
"href": "https://claude.ai/new?q={{ prompt }}",
"promptTemplate": "Read {{ page_url }} so I can ask questions about it.",
"analyticsKey": "open_page_markdown_claude"
}
]
}
123 changes: 123 additions & 0 deletions plugins/ai_file_utils/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import json
import logging
import re
import urllib.parse
from pathlib import Path
from typing import Any, Dict, List, Optional
from mkdocs.plugins import BasePlugin

# Configure Logger
log = logging.getLogger("mkdocs.plugins.ai_file_utils")

class AIFileUtilsPlugin(BasePlugin):
"""
A MkDocs plugin that provides utilities for resolving AI file actions.
This plugin acts as a library/service for other plugins to resolve
links, clipboard content, and LLM prompts based on a defined schema.
"""

def __init__(self):
self._actions_schema = None
self._actions_config_path = Path(__file__).parent / "ai_file_actions.json"

def on_config(self, config, **kwargs):
"""
Load the actions schema when the configuration is loaded.
"""
self._load_actions_schema()
return config

def _load_actions_schema(self):
"""
Loads the actions definition from the JSON file.
"""
try:
if self._actions_config_path.exists():
text = self._actions_config_path.read_text(encoding="utf-8")
self._actions_schema = json.loads(text)
log.info(f"[ai_file_utils] Loaded actions schema from {self._actions_config_path}")
else:
log.warning(f"[ai_file_utils] Actions schema file not found at {self._actions_config_path}")
self._actions_schema = {"actions": []}
except Exception as e:
log.error(f"[ai_file_utils] Failed to load actions schema: {e}")
self._actions_schema = {"actions": []}

def resolve_actions(self, page_url: str, filename: str, content: str) -> List[Dict[str, Any]]:
"""
Resolves the list of actions for a given page context.

Args:
page_url: The absolute URL to the markdown file (ai artifact).
filename: The name of the file (e.g., 'page.md').
content: The actual text content of the markdown file.

Returns:
A list of action dictionaries with all placeholders resolved.
"""
if not self._actions_schema:
self._load_actions_schema()

resolved_actions = []
raw_actions = self._actions_schema.get("actions", [])

for action_def in raw_actions:
try:
resolved_action = self._resolve_single_action(action_def, page_url, filename, content)
resolved_actions.append(resolved_action)
except Exception as e:
log.warning(f"[ai_file_utils] Failed to resolve action {action_def.get('id')}: {e}")

return resolved_actions

def _resolve_single_action(self, action_def: Dict[str, Any], page_url: str, filename: str, content: str) -> Dict[str, Any]:
"""
Resolves a single action definition by replacing placeholders.
"""
# Create a copy to avoid modifying the schema
action = action_def.copy()

# 1. Resolve Prompt if a template exists
prompt_text = ""
if "promptTemplate" in action:
tpl = action["promptTemplate"]
# Apply replacements to the prompt template first
# We construct a specific dict for prompt replacements to avoid circular dependency with "{{ prompt }}"
# and to handle content/url availability
prompt_replacements = {
"{{ content }}": content,
"{{ page_url }}": page_url,
"{{ filename }}": filename
}
for placeholder, replacement in prompt_replacements.items():
if placeholder in tpl:
tpl = tpl.replace(placeholder, replacement)
prompt_text = tpl

# Remove the template from the output as it's processed
# action.pop("promptTemplate")

# 2. Prepare Context Variables
# URL encode the prompt for use in query parameters
encoded_prompt = urllib.parse.quote_plus(prompt_text)

replacements = {
"{{ page_url }}": page_url,
"{{ filename }}": filename,
"{{ content }}": content, # Be careful with large content in attributes, but for clipboard it's needed
"{{ prompt }}": encoded_prompt
}

# 3. Interpolate values into specific fields
# Fields that support interpolation
target_fields = ["href", "download", "clipboardContent"]

for field in target_fields:
if field in action and isinstance(action[field], str):
val = action[field]
for placeholder, replacement in replacements.items():
if placeholder in val:
val = val.replace(placeholder, replacement)
action[field] = val

return action
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ minify = "plugins.minify.plugin:MinifyPlugin"
copy_md = "plugins.copy_md.plugin:CopyMDPlugin"
resolve_md = "plugins.resolve_md.plugin:ResolveMDPlugin"
page_toggle = "plugins.page_toggle.plugin:TogglePagesPlugin"
ai_file_utils = "plugins.ai_file_utils.plugin:AIFileUtilsPlugin"

# Configuration for development tools
[tool.black]
Expand Down
62 changes: 62 additions & 0 deletions tests/ai_file_utils/test_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import pytest
from plugins.ai_file_utils.plugin import AIFileUtilsPlugin

class TestAIFileUtilsPlugin:
def test_resolve_actions_usage(self):
"""
Demonstrates how to use the AIFileUtilsPlugin to resolve actions.
"""
# 1. Instantiate the plugin
# In a real MkDocs run, this happens automatically via entry points,
# but here we do it manually.
plugin = AIFileUtilsPlugin()

# 2. Initialize configuration (loading the JSON schema)
# We simulate the on_config lifecycle event
plugin.on_config({})

# 3. Define the context for a specific page
# This data usually comes from the processing loop in resolve_md
page_url = "https://docs.polkadot.com/ai/pages/basics.md"
filename = "basics.md"
content = "# Polkadot Basics\n\nPolkadot is a sharded protocol."

# 4. Call the public API: resolve_actions
actions = plugin.resolve_actions(page_url, filename, content)

# Usage Verification:
# Check that we got a list back
assert isinstance(actions, list)
assert len(actions) > 0

# Inspect the "View Markdown" action
view_action = next(a for a in actions if a["id"] == "view-markdown")
assert view_action["href"] == "https://docs.polkadot.com/ai/pages/basics.md"

# Inspect the "Download Markdown" action (check download attribute interpolation)
download_action = next(a for a in actions if a["id"] == "download-markdown")
assert download_action["download"] == "basics.md"

# Inspect the "ChatGPT" action (check prompt encoding)
chatgpt_action = next(a for a in actions if a["id"] == "open-chat-gpt")
# The prompt should be encoded in the URL
assert "chatgpt.com" in chatgpt_action["href"]
# Should contain encoded reference to jina.ai (part of the prompt now)
assert "r.jina.ai" in chatgpt_action["href"]

# Inspect the "Claude" action
claude_action = next(a for a in actions if a["id"] == "open-claude")
assert "claude.ai" in claude_action["href"]
# Should contain encoded page url as it's part of the prompt
assert "docs.polkadot.com" in claude_action["href"]

# This would be the structure consumed by the UI generator
print("\n--- Resolved Actions Example ---")
for action in actions:
print(f"Action ID: {action['id']}")
print(f" Type: {action['type']}")
print(f" Label: {action['label']}")
if "href" in action:
print(f" Href: {action['href'][:50]}...") # Truncated for display
if "clipboardContent" in action:
print(f" Clipboard: {action['clipboardContent'][:20]}...")
Loading