diff --git a/CMS-README.md b/CMS-README.md
index 0e7f18ceb..75181dfa7 100644
--- a/CMS-README.md
+++ b/CMS-README.md
@@ -69,6 +69,42 @@ From page `user-guide/concepts/agents/state.mdx`:
**Slug generation:** The content collection uses a custom `generateId` function in `src/content.config.ts` that shares the same normalization logic (`normalizePathToSlug`) as link resolution. This ensures consistency between how pages are identified and how links resolve to them.
+### 5. API Reference Links (`@api` shorthand)
+
+**What it does:** Provides a shorthand format for linking to API reference pages that's cleaner than relative paths.
+
+**Syntax:**
+```markdown
+
+[@api/python/strands.agent.agent](link text)
+[@api/python/strands.agent.agent#AgentResult](link text with anchor)
+
+
+[@api/typescript/Agent](link text)
+[@api/typescript/Agent#constructor](link text with anchor)
+```
+
+**How it works:**
+
+1. Links starting with `@api/` are detected by `isApiShorthand()` in `src/util/links.ts`
+2. `resolveApiShorthand()` converts them to absolute paths (e.g., `/api/python/strands.agent.agent/`)
+3. `PageLink.astro` applies the site's base path for correct URL generation
+
+**Why use this format:**
+- Cleaner than relative paths with `../api-reference/python/...`
+- Doesn't break when the linking page moves to a different directory
+- Matches the actual URL structure of the generated API docs
+- Validated against the content collection at build time
+
+**Examples:**
+```markdown
+
+[AgentResult](../api-reference/python/agent/agent_result.md#strands.agent.agent_result.AgentResult)
+
+
+[AgentResult](@api/python/strands.agent.agent_result#AgentResult)
+```
+
## Configuration (`astro.config.mjs`)
The main config ties everything together:
@@ -537,4 +573,33 @@ Each testimonial is a JSON file:
"icon": "https://example.com/company-logo.png",
"order": 1
}
-```
\ No newline at end of file
+```
+
+## Temporary Migration Files
+
+The following files were created to support the MkDocs → Astro migration and should be deleted once migration is complete:
+
+### Link Conversion Utilities
+
+These files handle converting old MkDocs-style API reference links to the new `@api` shorthand format:
+
+- `src/util/api-link-converter.ts` - Utility functions to detect and convert old API links
+- `test/api-link-converter.test.ts` - Tests for the link converter
+
+### Migration Scripts
+
+These scripts transform MkDocs markdown to Astro-compatible format at build time:
+
+- `scripts/update-docs.ts` - Main transformation script (converts admonitions, tabs, API links, etc.)
+- `scripts/update-quickstart.ts` - Quickstart-specific transformations
+- `test/update-docs.test.ts` - Tests for the update-docs transformations
+
+### When to Delete
+
+Once the migration is complete and all documentation is committed in Astro format:
+
+1. Run `npm run docs:update` one final time to apply all transformations
+2. Commit the transformed files directly (no longer keeping MkDocs format in source control)
+3. Delete the files listed above
+4. Remove the `docs:update` and `docs:revert` scripts from `package.json`
+5. Update this README to remove references to the migration process
diff --git a/CMS-TODO.md b/CMS-TODO.md
index 4c1e5acf5..7ed2c01c7 100644
--- a/CMS-TODO.md
+++ b/CMS-TODO.md
@@ -6,7 +6,8 @@
- [X] Add API documentation generation/integration for Python and TypeScript SDKs
- [ ] Fix type-checking
- [ ] Look into markdown
-- [ ] Add header links to Python/TypeScript method sections (api/python/strands.agent.agent/)
+- [X] Add header links to Python/TypeScript method sections (api/python/strands.agent.agent/)
+- [ ] Migrate all files and remove conversion scripts
## After Launch
- [ ] Move asset files to proper location (currently in `docs/assets/`, should be in `src/content/docs/assets/`)
diff --git a/scripts/update-docs.ts b/scripts/update-docs.ts
index 8c693d466..a87f66976 100644
--- a/scripts/update-docs.ts
+++ b/scripts/update-docs.ts
@@ -2,6 +2,7 @@ import { readdir, readFile, writeFile, mkdir, unlink } from "fs/promises";
import { join, dirname } from "path";
import { updateQuickstart } from "./update-quickstart.js";
import { getCommunityLabeledFiles } from "../src/sidebar.js";
+import { convertApiLink, isOldApiLink } from "../src/util/api-link-converter.js";
const DOCS_DIR = "docs";
const OUTPUT_DIR = "src/content/docs";
@@ -451,6 +452,33 @@ function convertBrToSelfClosing(content: string): string {
return content.replace(/
/gi, "
");
}
+/**
+ * Convert old MkDocs-style API reference links to the new @api shorthand format.
+ *
+ * Old formats:
+ * - Python: `../api-reference/python/agent/agent_result.md#strands.agent.agent_result.AgentResult`
+ * - TypeScript: `../api-reference/typescript/classes/BedrockModel.html`
+ *
+ * New formats:
+ * - Python: `@api/python/strands.agent.agent_result#AgentResult`
+ * - TypeScript: `@api/typescript/BedrockModel`
+ */
+function convertApiLinks(content: string): string {
+ // Match markdown links with potentially nested brackets in the text
+ // This handles cases like [`list[ToolSpec]`](url)
+ const markdownLinkPattern = /\[([^\]]*(?:\[[^\]]*\][^\]]*)*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
+
+ return content.replace(markdownLinkPattern, (match, text, url) => {
+ if (isOldApiLink(url)) {
+ const newUrl = convertApiLink(url);
+ if (newUrl) {
+ return `[${text}](${newUrl})`;
+ }
+ }
+ return match;
+ });
+}
+
/**
* Remove community_contribution_banner macro from content
* The banner is rendered via a component based on `community: true` frontmatter
@@ -601,6 +629,7 @@ function processFile(content: string, explicitTitle?: string, hasCommunityLabel?
newContent = convertMkdocsTabs(newContent);
newContent = convertHtmlCommentsToJsx(newContent);
newContent = convertBrToSelfClosing(newContent);
+ newContent = convertApiLinks(newContent);
// Handle H1 heading and title frontmatter
const h1Title = extractH1Title(newContent);
diff --git a/scripts/update-quickstart.ts b/scripts/update-quickstart.ts
index 0832f67db..eaa5f01f8 100644
--- a/scripts/update-quickstart.ts
+++ b/scripts/update-quickstart.ts
@@ -52,7 +52,7 @@ function convertQuickstartToCards(content: string): string {
`
);
@@ -64,7 +64,7 @@ function convertQuickstartToCards(content: string): string {
`
`
diff --git a/src/components/PageLink.astro b/src/components/PageLink.astro
index a3a5f54e2..05bc7aa8f 100644
--- a/src/components/PageLink.astro
+++ b/src/components/PageLink.astro
@@ -19,17 +19,13 @@ const { resolvedHref, found } = resolveHref(href, currentPath, docSlugs)
// Log warning in development if link wasn't found
if (!found) {
- if (!href.includes("/api-reference/")) {
- const route = Astro.locals.starlightRoute
- console.warn([
- `[PageLink] On page "${route?.entry?.filePath}"`,
- `could not resolve "${href}"`,
- `currentPath: "${currentPath}"`,
- ].join(', '))
- }
+ const route = Astro.locals.starlightRoute
+ console.warn([
+ `[PageLink] On page "${route?.entry?.filePath}"`,
+ `could not resolve "${href}"`,
+ `currentPath: "${currentPath}"`,
+ ].join(', '))
}
---
-
-
-
+
diff --git a/src/util/api-link-converter.ts b/src/util/api-link-converter.ts
new file mode 100644
index 000000000..906f62875
--- /dev/null
+++ b/src/util/api-link-converter.ts
@@ -0,0 +1,136 @@
+/**
+ * Utility to convert old MkDocs-style API reference links to the new @api shorthand format.
+ *
+ * Old formats:
+ * - Python: `../api-reference/python/agent/agent_result.md#strands.agent.agent_result.AgentResult`
+ * - TypeScript: `../api-reference/typescript/classes/BedrockModel.html`
+ *
+ * New formats:
+ * - Python: `@api/python/strands.agent.agent_result#AgentResult`
+ * - TypeScript: `@api/typescript/BedrockModel`
+ */
+
+/**
+ * Pattern to match old Python API links.
+ * Captures: path segments and optional hash with full dotted path
+ */
+const PYTHON_API_PATTERN = /^(\.\.\/)*api-reference\/python\/([^#]+)\.md(#(.+))?$/
+
+/**
+ * Pattern to match old TypeScript API links.
+ * Captures: classes/interfaces subdirectory and the type name
+ */
+const TS_API_PATTERN = /^(\.\.\/)*api-reference\/typescript\/(?:classes|interfaces)\/([^.]+)\.html(#(.+))?$/
+
+/**
+ * Check if a link is an old-style API reference link that needs conversion.
+ */
+export function isOldApiLink(link: string): boolean {
+ return PYTHON_API_PATTERN.test(link) || TS_API_PATTERN.test(link)
+}
+
+/**
+ * Convert an old Python API link to the new @api shorthand format.
+ *
+ * The hash fragment contains the full dotted path (e.g., `strands.agent.agent_result.AgentResult`).
+ * We extract the module path (everything up to the last segment) and the symbol (last segment).
+ *
+ * Examples:
+ * - `../api-reference/python/agent/agent_result.md#strands.agent.agent_result.AgentResult`
+ * -> `@api/python/strands.agent.agent_result#AgentResult`
+ * - `../api-reference/python/models/model.md#strands.models.model.Model.get_config`
+ * -> `@api/python/strands.models.model#Model.get_config`
+ * - `../api-reference/python/models/model.md` (no hash)
+ * -> `@api/python/strands.models.model`
+ */
+export function convertPythonApiLink(link: string): string | null {
+ const match = link.match(PYTHON_API_PATTERN)
+ if (!match) return null
+
+ const pathPart = match[2] ?? '' // e.g., "agent/agent_result" or "models/model"
+ const hashContent = match[4] // e.g., "strands.agent.agent_result.AgentResult" or undefined
+
+ if (hashContent) {
+ // Hash contains the full dotted path - extract module and symbol
+ // The module path is typically the part that matches the file structure
+ // The symbol is what comes after (class name, method, etc.)
+
+ // Find where the module path ends and the symbol begins
+ // Module paths follow the pattern: strands.{path segments matching file structure}
+ const pathSegments = pathPart.split('/')
+ const modulePrefix = 'strands.' + pathSegments.join('.')
+
+ if (hashContent.startsWith(modulePrefix)) {
+ // Everything after the module prefix is the symbol
+ const symbolPart = hashContent.slice(modulePrefix.length)
+ if (symbolPart.startsWith('.')) {
+ // There's a symbol after the module path
+ return `@api/python/${modulePrefix}#${symbolPart.slice(1)}`
+ } else if (symbolPart === '') {
+ // Hash points to the module itself
+ return `@api/python/${modulePrefix}`
+ }
+ }
+
+ // Fallback: use the hash content directly to determine module
+ // This handles cases where the hash might not perfectly match the path
+ const hashParts = hashContent.split('.')
+ // Find the likely module boundary (usually before a capitalized class name)
+ let moduleEndIndex = hashParts.length
+ for (let i = 1; i < hashParts.length; i++) {
+ const part = hashParts[i]
+ if (part && /^[A-Z]/.test(part)) {
+ moduleEndIndex = i
+ break
+ }
+ }
+
+ const modulePath = hashParts.slice(0, moduleEndIndex).join('.')
+ const symbol = hashParts.slice(moduleEndIndex).join('.')
+
+ if (symbol) {
+ return `@api/python/${modulePath}#${symbol}`
+ } else {
+ return `@api/python/${modulePath}`
+ }
+ } else {
+ // No hash - convert path to dotted module notation
+ const modulePath = 'strands.' + pathPart.split('/').join('.')
+ return `@api/python/${modulePath}`
+ }
+}
+
+/**
+ * Convert an old TypeScript API link to the new @api shorthand format.
+ *
+ * Examples:
+ * - `../api-reference/typescript/classes/BedrockModel.html` -> `@api/typescript/BedrockModel`
+ * - `../api-reference/typescript/interfaces/BedrockModelOptions.html` -> `@api/typescript/BedrockModelOptions`
+ * - `../api-reference/typescript/classes/Agent.html#constructor` -> `@api/typescript/Agent#constructor`
+ */
+export function convertTypeScriptApiLink(link: string): string | null {
+ const match = link.match(TS_API_PATTERN)
+ if (!match) return null
+
+ const typeName = match[2] // e.g., "BedrockModel"
+ const anchor = match[4] // e.g., "constructor" or undefined
+
+ if (anchor) {
+ return `@api/typescript/${typeName}#${anchor}`
+ }
+ return `@api/typescript/${typeName}`
+}
+
+/**
+ * Convert any old-style API link to the new @api shorthand format.
+ * Returns null if the link is not an API reference link.
+ */
+export function convertApiLink(link: string): string | null {
+ if (PYTHON_API_PATTERN.test(link)) {
+ return convertPythonApiLink(link)
+ }
+ if (TS_API_PATTERN.test(link)) {
+ return convertTypeScriptApiLink(link)
+ }
+ return null
+}
diff --git a/src/util/links.ts b/src/util/links.ts
index 20432d448..d1a050f8b 100644
--- a/src/util/links.ts
+++ b/src/util/links.ts
@@ -3,6 +3,40 @@
* using slugs everywhere, though that's always an option.
*/
+/**
+ * Check if a link uses the @api shorthand format.
+ * Supported formats:
+ * - @api/python/strands.module.path
+ * - @api/python/strands.module.path#SubPath
+ * - @api/typescript/ClassName
+ * - @api/typescript/ClassName#method
+ *
+ * @param link - The href to check
+ * @returns true if the link uses @api shorthand
+ */
+export function isApiShorthand(link: string): boolean {
+ return link.startsWith('@api/')
+}
+
+/**
+ * Resolve an @api shorthand link to an absolute path.
+ *
+ * @param link - The @api shorthand link (e.g., `@api/python/strands.agent.agent`)
+ * @returns The resolved absolute path (e.g., `/api/python/strands.agent.agent/`)
+ */
+export function resolveApiShorthand(link: string): string {
+ // Remove the @api/ prefix
+ const withoutPrefix = link.slice(5) // '@api/'.length === 5
+
+ // Split path and anchor
+ const hashIndex = withoutPrefix.indexOf('#')
+ const pathPart = hashIndex !== -1 ? withoutPrefix.slice(0, hashIndex) : withoutPrefix
+ const anchor = hashIndex !== -1 ? withoutPrefix.slice(hashIndex) : ''
+
+ // The path is already in the correct format (python/strands.module or typescript/ClassName)
+ return `/api/${pathPart}/${anchor}`
+}
+
/**
* Get the site's base URL path, stripped of trailing slash for consistent concatenation.
* This is used to build URLs that work correctly when the site is deployed at a subpath.
@@ -193,6 +227,7 @@ export function findDocSlug(resolvedPath: string, docSlugs: Set): string
* Resolve a potentially relative href to an absolute Astro URL.
*
* This is the main entry point for link resolution. It handles:
+ * - @api shorthand links (e.g., `@api/python/strands.agent.agent`)
* - Absolute URLs (returned as-is)
* - Anchor-only links (returned as-is)
* - Relative MkDocs-style links (resolved against current path and doc collection)
@@ -213,6 +248,18 @@ export function resolveHref(
currentPath: string,
docSlugs: Set
): { resolvedHref: string; found: boolean } {
+ // Handle @api shorthand links
+ if (isApiShorthand(href)) {
+ const resolved = resolveApiShorthand(href)
+ // Extract the slug part (without leading/trailing slashes and anchor)
+ const hashIndex = resolved.indexOf('#')
+ const pathOnly = hashIndex !== -1 ? resolved.slice(0, hashIndex) : resolved
+ const slugPart = pathOnly.replace(/^\//, '').replace(/\/$/, '')
+ const found = docSlugs.has(slugPart)
+ // Apply base path for @api links since they resolve to absolute paths
+ return { resolvedHref: pathWithBase(resolved), found }
+ }
+
if (!isRelativeLink(href)) {
return { resolvedHref: href, found: true }
}
diff --git a/test/api-link-converter.test.ts b/test/api-link-converter.test.ts
new file mode 100644
index 000000000..042c7c309
--- /dev/null
+++ b/test/api-link-converter.test.ts
@@ -0,0 +1,598 @@
+import { describe, it, expect } from 'vitest'
+import { getCollection } from 'astro:content'
+import { readFile } from 'node:fs/promises'
+import path from 'node:path'
+import {
+ isOldApiLink,
+ convertPythonApiLink,
+ convertTypeScriptApiLink,
+ convertApiLink,
+} from '../src/util/api-link-converter'
+import { resolveHref } from '../src/util/links'
+
+/**
+ * Extract anchor IDs from MDX content.
+ * Looks for patterns like:
+ * and heading IDs like: ## Agent {#agent}
+ */
+function extractAnchorsFromMdx(content: string): Set {
+ const anchors = new Set()
+
+ // Match patterns
+ const anchorTagRegex = /]*>/g
+ let match
+ while ((match = anchorTagRegex.exec(content)) !== null) {
+ if (match[1]) anchors.add(match[1])
+ }
+
+ // Match heading IDs like ## Heading {#heading-id}
+ const headingIdRegex = /^#{1,6}\s+.+\s+\{#([^}]+)\}/gm
+ while ((match = headingIdRegex.exec(content)) !== null) {
+ if (match[1]) anchors.add(match[1])
+ }
+
+ return anchors
+}
+
+/**
+ * All broken links from broken-links-analysis.md with their expected conversions.
+ * Format: [oldLink, expectedNewLink]
+ */
+const BROKEN_LINKS_TEST_DATA: [string, string][] = [
+ // user-guide/quickstart.mdx
+ ['../api-reference/python/agent/agent_result.md#strands.agent.agent_result.AgentResult', '@api/python/strands.agent.agent_result#AgentResult'],
+ ['../api-reference/python/models/model.md#strands.models.model.Model.get_config', '@api/python/strands.models.model#Model.get_config'],
+ ['../api-reference/python/agent/agent.md#strands.agent.agent.Agent.stream_async', '@api/python/strands.agent.agent#Agent.stream_async'],
+ ['../api-reference/python/agent/agent.md#strands.agent.agent.Agent.invoke_async', '@api/python/strands.agent.agent#Agent.invoke_async'],
+
+ // user-guide/quickstart/python.mdx
+ ['../../api-reference/python/agent/agent_result.md#strands.agent.agent_result.AgentResult', '@api/python/strands.agent.agent_result#AgentResult'],
+ ['../../api-reference/python/models/model.md#strands.models.model.Model.get_config', '@api/python/strands.models.model#Model.get_config'],
+ ['../../api-reference/python/agent/agent.md#strands.agent.agent.Agent.stream_async', '@api/python/strands.agent.agent#Agent.stream_async'],
+ ['../../api-reference/python/agent/agent.md#strands.agent.agent.Agent.invoke_async', '@api/python/strands.agent.agent#Agent.invoke_async'],
+
+ // community/model-providers/*.mdx
+ ['../../api-reference/python/models/model.md', '@api/python/strands.models.model'],
+
+ // user-guide/concepts/interrupts.mdx
+ ['../../api-reference/python/types/interrupt.md#strands.types.interrupt', '@api/python/strands.types.interrupt'],
+
+ // user-guide/observability-evaluation/metrics.mdx
+ ['../../api-reference/python/agent/agent_result.md#strands.agent.agent_result.AgentResult', '@api/python/strands.agent.agent_result#AgentResult'],
+ ['../../api-reference/python/telemetry/metrics.md#strands.telemetry.metrics', '@api/python/strands.telemetry.metrics'],
+ ['../../api-reference/python/telemetry/metrics.md#strands.telemetry.metrics.EventLoopMetrics', '@api/python/strands.telemetry.metrics#EventLoopMetrics'],
+ ['../../api-reference/python/telemetry/metrics.md#strands.telemetry.metrics.AgentInvocation', '@api/python/strands.telemetry.metrics#AgentInvocation'],
+ ['../../api-reference/python/telemetry/metrics.md#strands.telemetry.metrics.ToolMetrics', '@api/python/strands.telemetry.metrics#ToolMetrics'],
+
+ // user-guide/concepts/experimental/agent-config.mdx
+ ['../../../api-reference/python/agent/agent.md#strands.agent.agent.Agent.__init__', '@api/python/strands.agent.agent#Agent.__init__'],
+
+ // examples/python/multi_agent_example/multi_agent_example.mdx
+ ['../../../api-reference/python/handlers/callback_handler.md#strands.handlers.callback_handler.PrintingCallbackHandler', '@api/python/strands.handlers.callback_handler#PrintingCallbackHandler'],
+
+ // user-guide/concepts/agents/conversation-management.mdx
+ ['../../../api-reference/python/agent/conversation_manager/null_conversation_manager.md#strands.agent.conversation_manager.null_conversation_manager.NullConversationManager', '@api/python/strands.agent.conversation_manager.null_conversation_manager#NullConversationManager'],
+ ['../../../api-reference/python/agent/conversation_manager/sliding_window_conversation_manager.md#strands.agent.conversation_manager.sliding_window_conversation_manager.SlidingWindowConversationManager', '@api/python/strands.agent.conversation_manager.sliding_window_conversation_manager#SlidingWindowConversationManager'],
+ ['../../../api-reference/python/agent/conversation_manager/summarizing_conversation_manager.md#strands.agent.conversation_manager.summarizing_conversation_manager.SummarizingConversationManager', '@api/python/strands.agent.conversation_manager.summarizing_conversation_manager#SummarizingConversationManager'],
+ ['../../../api-reference/python/agent/conversation_manager/conversation_manager.md#strands.agent.conversation_manager.conversation_manager.ConversationManager', '@api/python/strands.agent.conversation_manager.conversation_manager#ConversationManager'],
+ ['../../../api-reference/python/agent/conversation_manager/conversation_manager.md#strands.agent.conversation_manager.conversation_manager.ConversationManager.apply_management', '@api/python/strands.agent.conversation_manager.conversation_manager#ConversationManager.apply_management'],
+ ['../../../api-reference/python/agent/conversation_manager/conversation_manager.md#strands.agent.conversation_manager.conversation_manager.ConversationManager.reduce_context', '@api/python/strands.agent.conversation_manager.conversation_manager#ConversationManager.reduce_context'],
+
+ // user-guide/concepts/agents/hooks.mdx
+ ['../../../api-reference/python/hooks/events.md#strands.hooks.events.AfterModelCallEvent', '@api/python/strands.hooks.events#AfterModelCallEvent'],
+ ['../../../api-reference/python/hooks/events.md#strands.hooks.events.BeforeToolCallEvent', '@api/python/strands.hooks.events#BeforeToolCallEvent'],
+ ['../../../api-reference/python/hooks/events.md#strands.hooks.events.AfterToolCallEvent', '@api/python/strands.hooks.events#AfterToolCallEvent'],
+
+ // user-guide/concepts/agents/prompts.mdx
+ ['../../../api-reference/python/types/content.md#strands.types.content.ContentBlock', '@api/python/strands.types.content#ContentBlock'],
+
+ // user-guide/concepts/agents/session-management.mdx
+ ['../../../api-reference/python/session/file_session_manager.md#strands.session.file_session_manager.FileSessionManager', '@api/python/strands.session.file_session_manager#FileSessionManager'],
+ ['../../../api-reference/python/session/s3_session_manager.md#strands.session.s3_session_manager.S3SessionManager', '@api/python/strands.session.s3_session_manager#S3SessionManager'],
+ ['../../../api-reference/python/types/session.md#strands.types.session.Session', '@api/python/strands.types.session#Session'],
+ ['../../../api-reference/python/types/session.md#strands.types.session.SessionAgent', '@api/python/strands.types.session#SessionAgent'],
+ ['../../../api-reference/python/types/session.md#strands.types.session.SessionMessage', '@api/python/strands.types.session#SessionMessage'],
+
+ // user-guide/concepts/agents/state.mdx
+ ['../../../api-reference/python/agent/conversation_manager/sliding_window_conversation_manager.md#strands.agent.conversation_manager.sliding_window_conversation_manager.SlidingWindowConversationManager', '@api/python/strands.agent.conversation_manager.sliding_window_conversation_manager#SlidingWindowConversationManager'],
+
+ // user-guide/concepts/agents/structured-output.mdx
+ ['../../../api-reference/python/agent/agent.md#strands.agent.agent', '@api/python/strands.agent.agent'],
+ ['../../../api-reference/python/agent/agent_result.md#strands.agent.agent_result', '@api/python/strands.agent.agent_result'],
+
+ // user-guide/concepts/bidirectional-streaming/*.mdx
+ ['../../../api-reference/python/experimental/bidi/agent/agent.md', '@api/python/strands.experimental.bidi.agent.agent'],
+
+ // user-guide/concepts/multi-agent/graph.mdx
+ ['../../../api-reference/python/multiagent/graph.md#strands.multiagent.graph.GraphNode', '@api/python/strands.multiagent.graph#GraphNode'],
+ ['../../../api-reference/python/multiagent/graph.md#strands.multiagent.graph.GraphEdge', '@api/python/strands.multiagent.graph#GraphEdge'],
+ ['../../../api-reference/python/multiagent/graph.md#strands.multiagent.graph.GraphBuilder', '@api/python/strands.multiagent.graph#GraphBuilder'],
+ ['../../../api-reference/python/multiagent/graph.md#strands.multiagent.graph.Graph', '@api/python/strands.multiagent.graph#Graph'],
+ ['../../../api-reference/python/multiagent/graph.md#strands.multiagent.graph.Graph.invoke_async', '@api/python/strands.multiagent.graph#Graph.invoke_async'],
+ ['../../../api-reference/python/multiagent/graph.md#strands.multiagent.graph.Graph.stream_async', '@api/python/strands.multiagent.graph#Graph.stream_async'],
+ ['../../../api-reference/python/multiagent/graph.md#strands.multiagent.graph.GraphResult', '@api/python/strands.multiagent.graph#GraphResult'],
+ ['../../../api-reference/python/multiagent/swarm.md#strands.multiagent.swarm.Swarm', '@api/python/strands.multiagent.swarm#Swarm'],
+ ['../../../api-reference/python/multiagent/base.md#strands.multiagent.base.MultiAgentBase', '@api/python/strands.multiagent.base#MultiAgentBase'],
+ ['../../../api-reference/python/types/content.md#strands.types.content.ContentBlock', '@api/python/strands.types.content#ContentBlock'],
+
+ // user-guide/concepts/multi-agent/swarm.mdx
+ ['../../../api-reference/python/multiagent/swarm.md#strands.multiagent.swarm.Swarm.invoke_async', '@api/python/strands.multiagent.swarm#Swarm.invoke_async'],
+ ['../../../api-reference/python/multiagent/swarm.md#strands.multiagent.swarm.Swarm.stream_async', '@api/python/strands.multiagent.swarm#Swarm.stream_async'],
+ ['../../../api-reference/python/multiagent/swarm.md#strands.multiagent.swarm.SwarmResult', '@api/python/strands.multiagent.swarm#SwarmResult'],
+
+ // user-guide/concepts/model-providers/amazon-bedrock.mdx
+ ['../../../api-reference/python/models/bedrock.md#strands.models.bedrock', '@api/python/strands.models.bedrock'],
+ ['../../../api-reference/typescript/classes/BedrockModel.html', '@api/typescript/BedrockModel'],
+ ['../../../api-reference/typescript/interfaces/BedrockModelOptions.html', '@api/typescript/BedrockModelOptions'],
+ ['../../../api-reference/python/types/content.md', '@api/python/strands.types.content'],
+
+ // user-guide/concepts/model-providers/anthropic.mdx
+ ['../../../api-reference/python/agent/agent.md#strands.agent.agent.Agent.structured_output', '@api/python/strands.agent.agent#Agent.structured_output'],
+ ['../../../api-reference/python/models/model.md', '@api/python/strands.models.model'],
+
+ // user-guide/concepts/model-providers/ollama.mdx
+ ['../../../api-reference/python/models/ollama.md#strands.models.ollama', '@api/python/strands.models.ollama'],
+ ['../../../api-reference/python/models/ollama.md#strands.models.ollama.OllamaModel.OllamaConfig', '@api/python/strands.models.ollama#OllamaModel.OllamaConfig'],
+
+ // user-guide/concepts/model-providers/custom_model_provider.mdx
+ ['../../../api-reference/python/types/content.md#strands.types.content.Messages', '@api/python/strands.types.content#Messages'],
+ ['../../../api-reference/python/types/content.md#strands.types.content.Role', '@api/python/strands.types.content#Role'],
+ ['../../../api-reference/python/types/content.md#strands.types.content.ContentBlockStartToolUse', '@api/python/strands.types.content#ContentBlockStartToolUse'],
+ ['../../../api-reference/python/types/tools.md#strands.types.tools.ToolSpec', '@api/python/strands.types.tools#ToolSpec'],
+ ['../../../api-reference/python/types/streaming.md#strands.types.streaming.StreamEvent', '@api/python/strands.types.streaming#StreamEvent'],
+ ['../../../api-reference/python/types/streaming.md#strands.types.streaming.MessageStartEvent', '@api/python/strands.types.streaming#MessageStartEvent'],
+ ['../../../api-reference/python/types/streaming.md#strands.types.streaming.ContentBlockStartEvent', '@api/python/strands.types.streaming#ContentBlockStartEvent'],
+ ['../../../api-reference/python/types/streaming.md#strands.types.streaming.ContentBlockDeltaEvent', '@api/python/strands.types.streaming#ContentBlockDeltaEvent'],
+ ['../../../api-reference/python/types/streaming.md#strands.types.streaming.ContentBlockStopEvent', '@api/python/strands.types.streaming#ContentBlockStopEvent'],
+ ['../../../api-reference/python/types/streaming.md#strands.types.streaming.MessageStopEvent', '@api/python/strands.types.streaming#MessageStopEvent'],
+ ['../../../api-reference/python/types/streaming.md#strands.types.streaming.MetadataEvent', '@api/python/strands.types.streaming#MetadataEvent'],
+ ['../../../api-reference/python/types/streaming.md#strands.types.streaming.RedactContentEvent', '@api/python/strands.types.streaming#RedactContentEvent'],
+ ['../../../api-reference/python/types/event_loop.md#strands.types.event_loop.StopReason', '@api/python/strands.types.event_loop#StopReason'],
+
+ // user-guide/concepts/tools/custom-tools.mdx
+ ['../../../api-reference/python/tools/decorator.md#strands.tools.decorator.tool', '@api/python/strands.tools.decorator#tool'],
+ ['../../../api-reference/python/types/tools.md#strands.types.tools.ToolContext', '@api/python/strands.types.tools#ToolContext'],
+ ['../../../api-reference/python/types/tools.md#strands.types.tools.ToolResult', '@api/python/strands.types.tools#ToolResult'],
+
+ // user-guide/concepts/tools/index.mdx
+ ['../../../api-reference/python/tools/decorator.md#strands.tools.decorator.tool', '@api/python/strands.tools.decorator#tool'],
+
+ // user-guide/concepts/streaming/async-iterators.mdx
+ ['../../../api-reference/python/agent/agent.md#strands.agent.agent.Agent.stream_async', '@api/python/strands.agent.agent#Agent.stream_async'],
+ ['../../../api-reference/python/agent/agent.md#strands.agent.agent.Agent.invoke_async', '@api/python/strands.agent.agent#Agent.invoke_async'],
+ ['../../../api-reference/python/agent/agent.md', '@api/python/strands.agent.agent'],
+
+ // user-guide/concepts/streaming/index.mdx
+ ['../../../api-reference/python/agent/agent_result.md#strands.agent.agent_result.AgentResult', '@api/python/strands.agent.agent_result#AgentResult'],
+ ['../../../api-reference/python/types/tools.md#strands.types.tools.ToolUse', '@api/python/strands.types.tools#ToolUse'],
+
+ // user-guide/concepts/bidirectional-streaming/models/gemini_live.mdx
+ ['../../../../api-reference/python/experimental/bidi/types/model.md#strands.experimental.bidi.types.model.AudioConfig', '@api/python/strands.experimental.bidi.types.model#AudioConfig'],
+ ['../../../../api-reference/python/experimental/bidi/models/gemini_live.md#strands.experimental.bidi.models.gemini_live.BidiGeminiLiveModel', '@api/python/strands.experimental.bidi.models.gemini_live#BidiGeminiLiveModel'],
+
+ // user-guide/concepts/bidirectional-streaming/models/nova_sonic.mdx
+ ['../../../../api-reference/python/experimental/bidi/types/model.md#strands.experimental.bidi.types.model.AudioConfig', '@api/python/strands.experimental.bidi.types.model#AudioConfig'],
+ ['../../../../api-reference/python/experimental/bidi/models/nova_sonic.md#strands.experimental.bidi.models.nova_sonic.BidiNovaSonicModel', '@api/python/strands.experimental.bidi.models.nova_sonic#BidiNovaSonicModel'],
+
+ // user-guide/concepts/bidirectional-streaming/models/openai_realtime.mdx
+ ['../../../../api-reference/python/experimental/bidi/types/model.md#strands.experimental.bidi.types.model.AudioConfig', '@api/python/strands.experimental.bidi.types.model#AudioConfig'],
+ ['../../../../api-reference/python/experimental/bidi/models/openai_realtime.md#strands.experimental.bidi.models.openai_realtime.BidiOpenAIRealtimeModel', '@api/python/strands.experimental.bidi.models.openai_realtime#BidiOpenAIRealtimeModel'],
+]
+
+describe('API Link Converter', () => {
+ describe('isOldApiLink', () => {
+ it('should detect old Python API links', () => {
+ expect(isOldApiLink('../api-reference/python/agent/agent_result.md')).toBe(true)
+ expect(isOldApiLink('../../api-reference/python/models/model.md')).toBe(true)
+ expect(isOldApiLink('../../../api-reference/python/agent/agent.md#strands.agent.agent.Agent')).toBe(true)
+ })
+
+ it('should detect old TypeScript API links', () => {
+ expect(isOldApiLink('../api-reference/typescript/classes/BedrockModel.html')).toBe(true)
+ expect(isOldApiLink('../../api-reference/typescript/interfaces/BedrockModelOptions.html')).toBe(true)
+ })
+
+ it('should not detect non-API links', () => {
+ expect(isOldApiLink('../user-guide/quickstart.md')).toBe(false)
+ expect(isOldApiLink('@api/python/strands.agent.agent')).toBe(false)
+ expect(isOldApiLink('https://example.com')).toBe(false)
+ expect(isOldApiLink('./sibling.md')).toBe(false)
+ })
+ })
+
+ describe('convertPythonApiLink', () => {
+ it('should convert links without hash', () => {
+ expect(convertPythonApiLink('../api-reference/python/models/model.md')).toBe('@api/python/strands.models.model')
+ expect(convertPythonApiLink('../../api-reference/python/agent/agent.md')).toBe('@api/python/strands.agent.agent')
+ })
+
+ it('should convert links with class hash', () => {
+ expect(
+ convertPythonApiLink('../api-reference/python/agent/agent_result.md#strands.agent.agent_result.AgentResult')
+ ).toBe('@api/python/strands.agent.agent_result#AgentResult')
+ })
+
+ it('should convert links with method hash', () => {
+ expect(
+ convertPythonApiLink('../api-reference/python/models/model.md#strands.models.model.Model.get_config')
+ ).toBe('@api/python/strands.models.model#Model.get_config')
+
+ expect(
+ convertPythonApiLink('../api-reference/python/agent/agent.md#strands.agent.agent.Agent.stream_async')
+ ).toBe('@api/python/strands.agent.agent#Agent.stream_async')
+ })
+
+ it('should convert links with module-only hash', () => {
+ expect(convertPythonApiLink('../api-reference/python/types/interrupt.md#strands.types.interrupt')).toBe(
+ '@api/python/strands.types.interrupt'
+ )
+ })
+
+ it('should convert nested module paths', () => {
+ expect(
+ convertPythonApiLink(
+ '../api-reference/python/agent/conversation_manager/sliding_window_conversation_manager.md#strands.agent.conversation_manager.sliding_window_conversation_manager.SlidingWindowConversationManager'
+ )
+ ).toBe('@api/python/strands.agent.conversation_manager.sliding_window_conversation_manager#SlidingWindowConversationManager')
+ })
+
+ it('should convert experimental module paths', () => {
+ expect(convertPythonApiLink('../api-reference/python/experimental/bidi/agent/agent.md')).toBe(
+ '@api/python/strands.experimental.bidi.agent.agent'
+ )
+ })
+
+ it('should handle various relative path depths', () => {
+ expect(convertPythonApiLink('../api-reference/python/models/bedrock.md')).toBe(
+ '@api/python/strands.models.bedrock'
+ )
+ expect(convertPythonApiLink('../../api-reference/python/models/bedrock.md')).toBe(
+ '@api/python/strands.models.bedrock'
+ )
+ expect(convertPythonApiLink('../../../api-reference/python/models/bedrock.md')).toBe(
+ '@api/python/strands.models.bedrock'
+ )
+ expect(convertPythonApiLink('../../../../api-reference/python/models/bedrock.md')).toBe(
+ '@api/python/strands.models.bedrock'
+ )
+ })
+
+ it('should return null for non-Python API links', () => {
+ expect(convertPythonApiLink('../user-guide/quickstart.md')).toBe(null)
+ expect(convertPythonApiLink('../api-reference/typescript/classes/Agent.html')).toBe(null)
+ })
+ })
+
+ describe('convertTypeScriptApiLink', () => {
+ it('should convert class links', () => {
+ expect(convertTypeScriptApiLink('../api-reference/typescript/classes/BedrockModel.html')).toBe(
+ '@api/typescript/BedrockModel'
+ )
+ expect(convertTypeScriptApiLink('../../api-reference/typescript/classes/Agent.html')).toBe(
+ '@api/typescript/Agent'
+ )
+ })
+
+ it('should convert interface links', () => {
+ expect(convertTypeScriptApiLink('../api-reference/typescript/interfaces/BedrockModelOptions.html')).toBe(
+ '@api/typescript/BedrockModelOptions'
+ )
+ })
+
+ it('should convert links with anchors', () => {
+ expect(convertTypeScriptApiLink('../api-reference/typescript/classes/Agent.html#constructor')).toBe(
+ '@api/typescript/Agent#constructor'
+ )
+ })
+
+ it('should handle various relative path depths', () => {
+ expect(convertTypeScriptApiLink('../api-reference/typescript/classes/Model.html')).toBe('@api/typescript/Model')
+ expect(convertTypeScriptApiLink('../../api-reference/typescript/classes/Model.html')).toBe('@api/typescript/Model')
+ expect(convertTypeScriptApiLink('../../../api-reference/typescript/classes/Model.html')).toBe(
+ '@api/typescript/Model'
+ )
+ })
+
+ it('should return null for non-TypeScript API links', () => {
+ expect(convertTypeScriptApiLink('../user-guide/quickstart.md')).toBe(null)
+ expect(convertTypeScriptApiLink('../api-reference/python/agent/agent.md')).toBe(null)
+ })
+ })
+
+ describe('convertApiLink', () => {
+ it('should convert Python API links', () => {
+ expect(convertApiLink('../api-reference/python/agent/agent.md#strands.agent.agent.Agent')).toBe(
+ '@api/python/strands.agent.agent#Agent'
+ )
+ })
+
+ it('should convert TypeScript API links', () => {
+ expect(convertApiLink('../api-reference/typescript/classes/BedrockModel.html')).toBe(
+ '@api/typescript/BedrockModel'
+ )
+ })
+
+ it('should return null for non-API links', () => {
+ expect(convertApiLink('../user-guide/quickstart.md')).toBe(null)
+ expect(convertApiLink('https://example.com')).toBe(null)
+ })
+ })
+
+ describe('Real-world examples from broken-links-analysis.md', () => {
+ // user-guide/quickstart.mdx
+ it('should convert quickstart.mdx links', () => {
+ expect(
+ convertApiLink('../api-reference/python/agent/agent_result.md#strands.agent.agent_result.AgentResult')
+ ).toBe('@api/python/strands.agent.agent_result#AgentResult')
+
+ expect(convertApiLink('../api-reference/python/models/model.md#strands.models.model.Model.get_config')).toBe(
+ '@api/python/strands.models.model#Model.get_config'
+ )
+
+ expect(convertApiLink('../api-reference/python/agent/agent.md#strands.agent.agent.Agent.stream_async')).toBe(
+ '@api/python/strands.agent.agent#Agent.stream_async'
+ )
+ })
+
+ // user-guide/concepts/agents/conversation-management.mdx
+ it('should convert conversation-management.mdx links', () => {
+ expect(
+ convertApiLink(
+ '../../../api-reference/python/agent/conversation_manager/null_conversation_manager.md#strands.agent.conversation_manager.null_conversation_manager.NullConversationManager'
+ )
+ ).toBe('@api/python/strands.agent.conversation_manager.null_conversation_manager#NullConversationManager')
+
+ expect(
+ convertApiLink(
+ '../../../api-reference/python/agent/conversation_manager/conversation_manager.md#strands.agent.conversation_manager.conversation_manager.ConversationManager.apply_management'
+ )
+ ).toBe('@api/python/strands.agent.conversation_manager.conversation_manager#ConversationManager.apply_management')
+ })
+
+ // user-guide/concepts/model-providers/amazon-bedrock.mdx
+ it('should convert amazon-bedrock.mdx links', () => {
+ expect(convertApiLink('../../../api-reference/python/models/bedrock.md#strands.models.bedrock')).toBe(
+ '@api/python/strands.models.bedrock'
+ )
+
+ expect(convertApiLink('../../../api-reference/typescript/classes/BedrockModel.html')).toBe(
+ '@api/typescript/BedrockModel'
+ )
+
+ expect(convertApiLink('../../../api-reference/typescript/interfaces/BedrockModelOptions.html')).toBe(
+ '@api/typescript/BedrockModelOptions'
+ )
+ })
+
+ // user-guide/concepts/bidirectional-streaming/models/gemini_live.mdx
+ it('should convert gemini_live.mdx links', () => {
+ expect(
+ convertApiLink(
+ '../../../../api-reference/python/experimental/bidi/types/model.md#strands.experimental.bidi.types.model.AudioConfig'
+ )
+ ).toBe('@api/python/strands.experimental.bidi.types.model#AudioConfig')
+
+ expect(
+ convertApiLink(
+ '../../../../api-reference/python/experimental/bidi/models/gemini_live.md#strands.experimental.bidi.models.gemini_live.BidiGeminiLiveModel'
+ )
+ ).toBe('@api/python/strands.experimental.bidi.models.gemini_live#BidiGeminiLiveModel')
+ })
+
+ // user-guide/concepts/tools/custom-tools.mdx
+ it('should convert custom-tools.mdx links', () => {
+ expect(convertApiLink('../../../api-reference/python/tools/decorator.md#strands.tools.decorator.tool')).toBe(
+ '@api/python/strands.tools.decorator#tool'
+ )
+
+ expect(convertApiLink('../../../api-reference/python/types/tools.md#strands.types.tools.ToolContext')).toBe(
+ '@api/python/strands.types.tools#ToolContext'
+ )
+ })
+
+ // Links without hash (just module reference)
+ it('should convert links without hash', () => {
+ expect(convertApiLink('../../api-reference/python/models/model.md')).toBe('@api/python/strands.models.model')
+
+ expect(convertApiLink('../../../api-reference/python/types/content.md')).toBe('@api/python/strands.types.content')
+
+ expect(convertApiLink('../../../api-reference/python/experimental/bidi/agent/agent.md')).toBe(
+ '@api/python/strands.experimental.bidi.agent.agent'
+ )
+ })
+ })
+
+ describe('Data-driven: All broken links from broken-links-analysis.md', () => {
+ it.each(BROKEN_LINKS_TEST_DATA)('converts %s -> %s', (oldLink, expectedNewLink) => {
+ const result = convertApiLink(oldLink)
+ expect(result).toBe(expectedNewLink)
+ })
+
+ it('should detect all test links as old API links', () => {
+ for (const [oldLink] of BROKEN_LINKS_TEST_DATA) {
+ expect(isOldApiLink(oldLink)).toBe(true)
+ }
+ })
+
+ it('should have converted all unique broken links', () => {
+ // Verify we have a reasonable number of test cases
+ const uniqueLinks = new Set(BROKEN_LINKS_TEST_DATA.map(([old]) => old))
+ expect(uniqueLinks.size).toBeGreaterThan(50)
+ console.log(`Tested ${uniqueLinks.size} unique broken link conversions`)
+ })
+ })
+
+ describe('Integration: Converted links resolve to real content', () => {
+ it('should resolve all converted @api links to actual pages in the content collection', async () => {
+ const docs = await getCollection('docs')
+ const docSlugs = new Set(docs.map((doc) => doc.id)) as Set
+
+ const unresolvedLinks: { oldLink: string; newLink: string; slug: string }[] = []
+
+ for (const [oldLink, expectedNewLink] of BROKEN_LINKS_TEST_DATA) {
+ const converted = convertApiLink(oldLink)
+ expect(converted).toBe(expectedNewLink)
+
+ // Now verify the converted link resolves to a real page
+ const { resolvedHref, found } = resolveHref(converted!, '/user-guide/quickstart/', docSlugs)
+
+ if (!found) {
+ // Extract the slug from the resolved href (remove leading/trailing slashes and anchor)
+ const slug = resolvedHref.replace(/^\//, '').replace(/\/$/, '').split('#')[0]
+ unresolvedLinks.push({ oldLink, newLink: converted!, slug: slug ?? '' })
+ }
+ }
+
+ if (unresolvedLinks.length > 0) {
+ console.log('\n=== Converted links that do not resolve to content ===\n')
+ for (const { oldLink, newLink, slug } of unresolvedLinks) {
+ console.log(`- ${oldLink}`)
+ console.log(` -> ${newLink}`)
+ console.log(` -> slug: ${slug} (NOT FOUND)`)
+ }
+ }
+
+ expect(unresolvedLinks).toEqual([])
+ })
+
+ it('should have all expected Python API pages in the content collection', async () => {
+ const docs = await getCollection('docs')
+ const docSlugs = new Set(docs.map((doc) => doc.id)) as Set
+
+ // Extract unique Python module paths from test data
+ const pythonModules = new Set()
+ for (const [, newLink] of BROKEN_LINKS_TEST_DATA) {
+ if (newLink.startsWith('@api/python/')) {
+ const modulePath = newLink.replace('@api/python/', '').split('#')[0]
+ pythonModules.add(modulePath ?? '')
+ }
+ }
+
+ const missingModules: string[] = []
+ for (const modulePath of pythonModules) {
+ const slug = `api/python/${modulePath}`
+ if (!docSlugs.has(slug)) {
+ missingModules.push(slug)
+ }
+ }
+
+ if (missingModules.length > 0) {
+ console.log('\n=== Missing Python API pages ===\n')
+ for (const slug of missingModules) {
+ console.log(`- ${slug}`)
+ }
+ }
+
+ expect(missingModules).toEqual([])
+ })
+
+ it('should have all expected TypeScript API pages in the content collection', async () => {
+ const docs = await getCollection('docs')
+ const docSlugs = new Set(docs.map((doc) => doc.id)) as Set
+
+ // Extract unique TypeScript type names from test data
+ const tsTypes = new Set()
+ for (const [, newLink] of BROKEN_LINKS_TEST_DATA) {
+ if (newLink.startsWith('@api/typescript/')) {
+ const typeName = newLink.replace('@api/typescript/', '').split('#')[0]
+ tsTypes.add(typeName ?? '')
+ }
+ }
+
+ const missingTypes: string[] = []
+ for (const typeName of tsTypes) {
+ const slug = `api/typescript/${typeName}`
+ if (!docSlugs.has(slug)) {
+ missingTypes.push(slug)
+ }
+ }
+
+ if (missingTypes.length > 0) {
+ console.log('\n=== Missing TypeScript API pages ===\n')
+ for (const slug of missingTypes) {
+ console.log(`- ${slug}`)
+ }
+ }
+
+ expect(missingTypes).toEqual([])
+ })
+
+ it('should have valid hash anchors in target Python API pages', async () => {
+ // Extract links with hashes that target Python API pages
+ const linksWithHashes: { newLink: string; slug: string; hash: string }[] = []
+
+ for (const [, newLink] of BROKEN_LINKS_TEST_DATA) {
+ if (newLink.startsWith('@api/python/') && newLink.includes('#')) {
+ const [pathPart, hash] = newLink.replace('@api/python/', '').split('#')
+ if (pathPart && hash) {
+ linksWithHashes.push({
+ newLink,
+ slug: `api/python/${pathPart}`,
+ hash,
+ })
+ }
+ }
+ }
+
+ const invalidAnchors: { newLink: string; slug: string; hash: string; availableAnchors: string[] }[] = []
+
+ // Group by slug to avoid reading the same file multiple times
+ const bySlug = new Map()
+ for (const item of linksWithHashes) {
+ const existing = bySlug.get(item.slug) || []
+ existing.push({ newLink: item.newLink, hash: item.hash })
+ bySlug.set(item.slug, existing)
+ }
+
+ for (const [slug, items] of bySlug) {
+ // Try to read the MDX file
+ const mdxPath = path.resolve(`src/content/docs/${slug}.mdx`)
+ let content: string
+ try {
+ content = await readFile(mdxPath, 'utf-8')
+ } catch {
+ // File doesn't exist - skip (covered by other tests)
+ continue
+ }
+
+ const anchors = extractAnchorsFromMdx(content)
+
+ for (const { newLink, hash } of items) {
+ // The hash in the new format is the symbol name (e.g., "Agent" or "Agent.stream_async")
+ // The anchor in the MDX is the full dotted path (e.g., "strands.agent.agent.Agent")
+ // We need to construct the expected anchor ID
+
+ // Extract the module path from the slug
+ const modulePath = slug.replace('api/python/', '')
+
+ // The full anchor ID is modulePath + "." + hash (for class/method references)
+ // But for simple class references, the anchor might just be modulePath.ClassName
+ const possibleAnchors = [
+ `${modulePath}.${hash}`, // e.g., strands.agent.agent.Agent
+ hash, // Just the hash itself
+ ]
+
+ const found = possibleAnchors.some((anchor) => anchors.has(anchor))
+
+ if (!found) {
+ // Get a sample of available anchors for debugging
+ const availableAnchors = Array.from(anchors).slice(0, 10)
+ invalidAnchors.push({ newLink, slug, hash, availableAnchors })
+ }
+ }
+ }
+
+ if (invalidAnchors.length > 0) {
+ console.log('\n=== Invalid hash anchors (not found in target file) ===\n')
+ for (const { newLink, slug, hash, availableAnchors } of invalidAnchors) {
+ console.log(`- ${newLink}`)
+ console.log(` File: ${slug}.mdx`)
+ console.log(` Hash: ${hash}`)
+ console.log(` Available anchors (sample): ${availableAnchors.join(', ')}`)
+ }
+ }
+
+ expect(invalidAnchors).toEqual([])
+ })
+ })
+})
diff --git a/test/links.test.ts b/test/links.test.ts
index 7687655f6..891e86fa2 100644
--- a/test/links.test.ts
+++ b/test/links.test.ts
@@ -1,8 +1,77 @@
import { describe, it, expect } from 'vitest'
import { getCollection } from 'astro:content'
-import { isRelativeLink, normalizePath, resolveRelativeLink, findDocSlug, resolveHref } from '../src/util/links'
+import {
+ isRelativeLink,
+ normalizePath,
+ resolveRelativeLink,
+ findDocSlug,
+ resolveHref,
+ isApiShorthand,
+ resolveApiShorthand,
+} from '../src/util/links'
describe('Link Utilities', () => {
+ describe('isApiShorthand', () => {
+ it('should return true for @api/python links', () => {
+ expect(isApiShorthand('@api/python/strands.agent.agent')).toBe(true)
+ expect(isApiShorthand('@api/python/strands.agent.agent_result')).toBe(true)
+ expect(isApiShorthand('@api/python/strands.models.bedrock')).toBe(true)
+ })
+
+ it('should return true for @api/typescript links', () => {
+ expect(isApiShorthand('@api/typescript/Agent')).toBe(true)
+ expect(isApiShorthand('@api/typescript/BedrockModel')).toBe(true)
+ })
+
+ it('should return true for @api links with anchors', () => {
+ expect(isApiShorthand('@api/python/strands.agent.agent#Agent')).toBe(true)
+ expect(isApiShorthand('@api/typescript/BedrockModel#constructor')).toBe(true)
+ })
+
+ it('should return false for non-@api links', () => {
+ expect(isApiShorthand('../api-reference/python/agent.md')).toBe(false)
+ expect(isApiShorthand('/api/python/strands.agent.agent/')).toBe(false)
+ expect(isApiShorthand('https://example.com')).toBe(false)
+ expect(isApiShorthand('relative/path.md')).toBe(false)
+ })
+ })
+
+ describe('resolveApiShorthand', () => {
+ it('should resolve Python API links', () => {
+ expect(resolveApiShorthand('@api/python/strands.agent.agent')).toBe('/api/python/strands.agent.agent/')
+ expect(resolveApiShorthand('@api/python/strands.agent.agent_result')).toBe(
+ '/api/python/strands.agent.agent_result/'
+ )
+ expect(resolveApiShorthand('@api/python/strands.models.bedrock')).toBe('/api/python/strands.models.bedrock/')
+ })
+
+ it('should resolve TypeScript API links', () => {
+ expect(resolveApiShorthand('@api/typescript/Agent')).toBe('/api/typescript/Agent/')
+ expect(resolveApiShorthand('@api/typescript/BedrockModel')).toBe('/api/typescript/BedrockModel/')
+ })
+
+ it('should preserve anchors', () => {
+ expect(resolveApiShorthand('@api/python/strands.agent.agent#Agent')).toBe('/api/python/strands.agent.agent/#Agent')
+ expect(resolveApiShorthand('@api/python/strands.agent.agent_result#AgentResult')).toBe(
+ '/api/python/strands.agent.agent_result/#AgentResult'
+ )
+ expect(resolveApiShorthand('@api/typescript/BedrockModel#constructor')).toBe(
+ '/api/typescript/BedrockModel/#constructor'
+ )
+ })
+
+ it('should handle nested module paths', () => {
+ expect(resolveApiShorthand('@api/python/strands.agent.conversation_manager.sliding_window_conversation_manager')).toBe(
+ '/api/python/strands.agent.conversation_manager.sliding_window_conversation_manager/'
+ )
+ expect(
+ resolveApiShorthand(
+ '@api/python/strands.experimental.bidi.models.gemini_live#BidiGeminiLiveModel'
+ )
+ ).toBe('/api/python/strands.experimental.bidi.models.gemini_live/#BidiGeminiLiveModel')
+ })
+ })
+
describe('isRelativeLink', () => {
it('should return false for absolute URLs', () => {
expect(isRelativeLink('http://example.com')).toBe(false)
@@ -203,10 +272,92 @@ describe('Link Utilities', () => {
expect(result.resolvedHref).toBe('/docs/sibling/')
expect(result.found).toBe(true)
})
+
+ it('should resolve @api/python shorthand links', () => {
+ const slugs = new Set(['api/python/strands.agent.agent', 'api/python/strands.agent.agent_result'])
+ const result = resolveHref('@api/python/strands.agent.agent', '/user-guide/quickstart/', slugs)
+ expect(result.resolvedHref).toBe('/api/python/strands.agent.agent/')
+ expect(result.found).toBe(true)
+ })
+
+ it('should resolve @api/typescript shorthand links', () => {
+ const slugs = new Set(['api/typescript/Agent', 'api/typescript/BedrockModel'])
+ const result = resolveHref('@api/typescript/BedrockModel', '/user-guide/quickstart/', slugs)
+ expect(result.resolvedHref).toBe('/api/typescript/BedrockModel/')
+ expect(result.found).toBe(true)
+ })
+
+ it('should resolve @api shorthand links with anchors', () => {
+ const slugs = new Set(['api/python/strands.agent.agent_result'])
+ const result = resolveHref('@api/python/strands.agent.agent_result#AgentResult', '/user-guide/quickstart/', slugs)
+ expect(result.resolvedHref).toBe('/api/python/strands.agent.agent_result/#AgentResult')
+ expect(result.found).toBe(true)
+ })
+
+ it('should mark @api shorthand links as not found when slug does not exist', () => {
+ const slugs = new Set(['api/python/strands.agent.agent'])
+ const result = resolveHref('@api/python/strands.nonexistent.module', '/user-guide/quickstart/', slugs)
+ expect(result.resolvedHref).toBe('/api/python/strands.nonexistent.module/')
+ expect(result.found).toBe(false)
+ })
})
})
describe('Link Resolution with Content Collection', () => {
+ it('should resolve @api shorthand links against real collection', async () => {
+ const docs = await getCollection('docs')
+ const docSlugs = new Set(docs.map((doc) => doc.id)) as Set
+
+ // Test Python API links
+ const pythonTests = [
+ { href: '@api/python/strands.agent.agent', expectedSlug: 'api/python/strands.agent.agent' },
+ { href: '@api/python/strands.agent.agent_result', expectedSlug: 'api/python/strands.agent.agent_result' },
+ { href: '@api/python/strands.models.bedrock', expectedSlug: 'api/python/strands.models.bedrock' },
+ {
+ href: '@api/python/strands.agent.conversation_manager.sliding_window_conversation_manager',
+ expectedSlug: 'api/python/strands.agent.conversation_manager.sliding_window_conversation_manager',
+ },
+ ]
+
+ for (const { href, expectedSlug } of pythonTests) {
+ const result = resolveHref(href, '/user-guide/quickstart/', docSlugs)
+ if (docSlugs.has(expectedSlug)) {
+ expect(result.found).toBe(true)
+ expect(result.resolvedHref).toBe(`/${expectedSlug}/`)
+ }
+ }
+
+ // Test TypeScript API links
+ const tsTests = [
+ { href: '@api/typescript/Agent', expectedSlug: 'api/typescript/Agent' },
+ { href: '@api/typescript/BedrockModel', expectedSlug: 'api/typescript/BedrockModel' },
+ ]
+
+ for (const { href, expectedSlug } of tsTests) {
+ const result = resolveHref(href, '/user-guide/quickstart/', docSlugs)
+ if (docSlugs.has(expectedSlug)) {
+ expect(result.found).toBe(true)
+ expect(result.resolvedHref).toBe(`/${expectedSlug}/`)
+ }
+ }
+ })
+
+ it('should resolve @api shorthand links with anchors against real collection', async () => {
+ const docs = await getCollection('docs')
+ const docSlugs = new Set(docs.map((doc) => doc.id)) as Set
+
+ const result = resolveHref(
+ '@api/python/strands.agent.agent_result#AgentResult',
+ '/user-guide/quickstart/',
+ docSlugs
+ )
+
+ if (docSlugs.has('api/python/strands.agent.agent_result')) {
+ expect(result.found).toBe(true)
+ expect(result.resolvedHref).toBe('/api/python/strands.agent.agent_result/#AgentResult')
+ }
+ })
+
it('should resolve common documentation links', async () => {
const docs = await getCollection('docs')
const docSlugs = new Set(docs.map((doc) => doc.id)) as Set
diff --git a/test/update-docs.test.ts b/test/update-docs.test.ts
new file mode 100644
index 000000000..2750723cc
--- /dev/null
+++ b/test/update-docs.test.ts
@@ -0,0 +1,130 @@
+import { describe, it, expect } from 'vitest'
+import { isOldApiLink, convertApiLink } from '../src/util/api-link-converter'
+
+/**
+ * Test the convertApiLinks function behavior by testing the underlying utilities
+ * that update-docs.ts uses.
+ */
+describe('update-docs API link conversion', () => {
+ /**
+ * Simulate the convertApiLinks function from update-docs.ts
+ * Uses a more robust regex that handles nested brackets in link text
+ */
+ function convertApiLinks(content: string): string {
+ // Match markdown links with potentially nested brackets in the text
+ // This handles cases like [`list[ToolSpec]`](url)
+ const markdownLinkPattern = /\[([^\]]*(?:\[[^\]]*\][^\]]*)*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
+
+ return content.replace(markdownLinkPattern, (match, text, url) => {
+ if (isOldApiLink(url)) {
+ const newUrl = convertApiLink(url)
+ if (newUrl) {
+ return `[${text}](${newUrl})`
+ }
+ }
+ return match
+ })
+ }
+
+ describe('convertApiLinks', () => {
+ it('should convert Python API links in markdown', () => {
+ const input = `See the [AgentResult](../api-reference/python/agent/agent_result.md#strands.agent.agent_result.AgentResult) class.`
+ const expected = `See the [AgentResult](@api/python/strands.agent.agent_result#AgentResult) class.`
+ expect(convertApiLinks(input)).toBe(expected)
+ })
+
+ it('should convert TypeScript API links in markdown', () => {
+ const input = `Use the [BedrockModel](../api-reference/typescript/classes/BedrockModel.html) class.`
+ const expected = `Use the [BedrockModel](@api/typescript/BedrockModel) class.`
+ expect(convertApiLinks(input)).toBe(expected)
+ })
+
+ it('should convert multiple API links in the same content', () => {
+ const input = `
+The [Agent](../api-reference/python/agent/agent.md#strands.agent.agent.Agent) class uses
+[BedrockModel](../../api-reference/typescript/classes/BedrockModel.html) by default.
+See also [AgentResult](../api-reference/python/agent/agent_result.md#strands.agent.agent_result.AgentResult).
+`
+ const expected = `
+The [Agent](@api/python/strands.agent.agent#Agent) class uses
+[BedrockModel](@api/typescript/BedrockModel) by default.
+See also [AgentResult](@api/python/strands.agent.agent_result#AgentResult).
+`
+ expect(convertApiLinks(input)).toBe(expected)
+ })
+
+ it('should not modify non-API links', () => {
+ const input = `
+See the [quickstart guide](../user-guide/quickstart.md) for more info.
+Check out [GitHub](https://github.com/strands-agents/sdk-python).
+`
+ expect(convertApiLinks(input)).toBe(input)
+ })
+
+ it('should handle links with titles', () => {
+ const input = `See [AgentResult](../api-reference/python/agent/agent_result.md#strands.agent.agent_result.AgentResult "The result class").`
+ const expected = `See [AgentResult](@api/python/strands.agent.agent_result#AgentResult).`
+ expect(convertApiLinks(input)).toBe(expected)
+ })
+
+ it('should handle links without hash anchors', () => {
+ const input = `See the [model module](../api-reference/python/models/model.md) for details.`
+ const expected = `See the [model module](@api/python/strands.models.model) for details.`
+ expect(convertApiLinks(input)).toBe(expected)
+ })
+
+ it('should handle deeply nested relative paths', () => {
+ const input = `See [AudioConfig](../../../../api-reference/python/experimental/bidi/types/model.md#strands.experimental.bidi.types.model.AudioConfig).`
+ const expected = `See [AudioConfig](@api/python/strands.experimental.bidi.types.model#AudioConfig).`
+ expect(convertApiLinks(input)).toBe(expected)
+ })
+
+ it('should handle TypeScript interface links', () => {
+ const input = `Configure with [BedrockModelOptions](../api-reference/typescript/interfaces/BedrockModelOptions.html).`
+ const expected = `Configure with [BedrockModelOptions](@api/typescript/BedrockModelOptions).`
+ expect(convertApiLinks(input)).toBe(expected)
+ })
+
+ it('should preserve link text exactly', () => {
+ const input = `The [\`Agent.stream_async()\`](../api-reference/python/agent/agent.md#strands.agent.agent.Agent.stream_async) method.`
+ const expected = `The [\`Agent.stream_async()\`](@api/python/strands.agent.agent#Agent.stream_async) method.`
+ expect(convertApiLinks(input)).toBe(expected)
+ })
+
+ it('should handle mixed content with code blocks', () => {
+ const input = `
+Use the [Agent](../api-reference/python/agent/agent.md#strands.agent.agent.Agent) class:
+
+\`\`\`python
+from strands import Agent
+agent = Agent()
+\`\`\`
+
+See [AgentResult](../api-reference/python/agent/agent_result.md#strands.agent.agent_result.AgentResult) for results.
+`
+ const expected = `
+Use the [Agent](@api/python/strands.agent.agent#Agent) class:
+
+\`\`\`python
+from strands import Agent
+agent = Agent()
+\`\`\`
+
+See [AgentResult](@api/python/strands.agent.agent_result#AgentResult) for results.
+`
+ expect(convertApiLinks(input)).toBe(expected)
+ })
+
+ it('should handle links with backticks in link text', () => {
+ const input = `- [\`list[ToolSpec]\`](../../../api-reference/python/types/tools.md#strands.types.tools.ToolSpec): List of tool specifications.`
+ const expected = `- [\`list[ToolSpec]\`](@api/python/strands.types.tools#ToolSpec): List of tool specifications.`
+ expect(convertApiLinks(input)).toBe(expected)
+ })
+
+ it('should handle links with square brackets in link text', () => {
+ const input = `See [\`dict[str, Any]\`](../api-reference/python/types/content.md#strands.types.content.ContentBlock) for details.`
+ const expected = `See [\`dict[str, Any]\`](@api/python/strands.types.content#ContentBlock) for details.`
+ expect(convertApiLinks(input)).toBe(expected)
+ })
+ })
+})