From 7db9b82dd347662d63f9b1089ac5c5b713409a7b Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Wed, 18 Feb 2026 12:43:24 -0500 Subject: [PATCH 1/2] fix: Fix links pointing to api documentation Add conversion scripts and a new format for linking to api docs. Instead of using links to [generated] pages directly, we introduce the format @api/python/strands.agent.agent for linking to the Agent Class. Also updated the conversion script to update api links to use the new format --- CMS-README.md | 67 +++- CMS-TODO.md | 3 +- scripts/update-docs.ts | 29 ++ scripts/update-quickstart.ts | 4 +- src/components/PageLink.astro | 18 +- src/util/api-link-converter.ts | 136 ++++++++ src/util/links.ts | 46 +++ test/api-link-converter.test.ts | 598 ++++++++++++++++++++++++++++++++ test/links.test.ts | 153 +++++++- test/update-docs.test.ts | 130 +++++++ 10 files changed, 1168 insertions(+), 16 deletions(-) create mode 100644 src/util/api-link-converter.ts create mode 100644 test/api-link-converter.test.ts create mode 100644 test/update-docs.test.ts 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..b2d4e3529 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,17 @@ 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) + return { resolvedHref: 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) + }) + }) +}) From 2bce85e4aac6a04c8ce5220886f1c277f3c2599a Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Wed, 18 Feb 2026 13:02:30 -0500 Subject: [PATCH 2/2] fix: Apply base path --- src/util/links.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/util/links.ts b/src/util/links.ts index b2d4e3529..d1a050f8b 100644 --- a/src/util/links.ts +++ b/src/util/links.ts @@ -256,7 +256,8 @@ export function resolveHref( const pathOnly = hashIndex !== -1 ? resolved.slice(0, hashIndex) : resolved const slugPart = pathOnly.replace(/^\//, '').replace(/\/$/, '') const found = docSlugs.has(slugPart) - return { resolvedHref: resolved, found } + // Apply base path for @api links since they resolve to absolute paths + return { resolvedHref: pathWithBase(resolved), found } } if (!isRelativeLink(href)) {