diff --git a/README.md b/README.md index 2aa946b..7d768b6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ Apple Developer Documentation MCP Server - Access Apple's official developer doc - 🔍 **Smart Search**: Intelligent search across Apple Developer Documentation for SwiftUI, UIKit, Foundation, CoreData, ARKit, and more - 📚 **Complete Documentation Access**: Full access to Apple's JSON API for Swift, Objective-C, and framework documentation +- 🎨 **Apple Design and HIG Access**: Read Human Interface Guidelines JSON, Apple Design pages, and Design Resources catalog entries +- 🖼️ **Design Resource Previews**: Return Apple-provided HIG images and resource thumbnails as MCP image content blocks +- 📦 **Downloadable Design Resources**: Download direct Apple-hosted templates, fonts, tools, and archives into a local MCP resource cache - 🔧 **Framework Index**: Browse hierarchical API structures for iOS, macOS, watchOS, tvOS, visionOS frameworks - 📋 **Technology Catalog**: Explore Apple technologies including SwiftUI, UIKit, Metal, Core ML, Vision, and ARKit - 📰 **Documentation Updates**: Track WWDC 2024/2025 announcements, iOS 26, macOS 26, and latest SDK releases @@ -269,6 +272,15 @@ npm install && npm run build "Get URLSession async/await methods" ``` +### 🎨 Apple Design and HIG +``` +"Search Apple Design docs for layout" +"Read the HIG page about color" +"List Apple Design Resources for iOS templates" +"Download the Apple Design resource with this resourceId" +"Show Apple Design examples for the layout HIG page" +``` + ### 🔧 Framework Exploration ``` "Show me SwiftUI framework API index" @@ -366,6 +378,11 @@ npm install && npm run build |------|-------------|--------------| | `search_apple_docs` | Search Apple Developer Documentation | Official search API, find specific APIs, classes, methods | | `get_apple_doc_content` | Get detailed documentation content | JSON API access, optional enhanced analysis (related/similar APIs, platform compatibility) | +| `search_apple_design_docs` | Search Apple Design and HIG content | HIG JSON references, Design pages, Design Resources catalog | +| `get_apple_design_content` | Read Apple Design and HIG pages | HIG JSON rendering, HTML fallback for `/design/` pages | +| `list_apple_design_resources` | List Apple Design Resources | Stable resource IDs, category/platform/format filters, previews and links | +| `download_apple_design_resource` | Download direct Apple Design resources | Local cache, MCP `resource_link` blocks, `resources/read` blob access | +| `get_apple_design_examples` | Return Apple Design visual examples | MCP `image` blocks with base64 data and MIME type | | `list_technologies` | Browse all Apple technologies | Category filtering, language support, beta status | | `search_framework_symbols` | Search symbols in specific framework | Classes, structs, protocols, wildcard patterns, type filtering | | `get_related_apis` | Find related APIs | Inheritance, conformance, "See Also" relationships | @@ -389,6 +406,7 @@ apple-docs-mcp/ │ ├── tools/ # MCP tool implementations │ │ ├── search-parser.ts # HTML search result parsing │ │ ├── doc-fetcher.ts # JSON API documentation fetching +│ │ ├── design-docs.ts # Apple Design, HIG, resources, and previews │ │ ├── list-technologies.ts # Technology catalog handling │ │ ├── get-documentation-updates.ts # Documentation updates tracking │ │ ├── get-technology-overviews.ts # Technology overviews and guides @@ -436,6 +454,10 @@ apple-docs-mcp/ | Framework Indexes | 1 hour | 100 entries | Stable structure, less frequent changes | | Technologies List | 2 hours | 50 entries | Rarely changes, large content | | Documentation Updates | 30 minutes | 100 entries | Regular updates, WWDC announcements | +| Apple Design Content | 2 hours | 100 entries | HIG and Design pages are stable during a session | +| Apple Design Resources | 2 hours | 20 entries | Catalog metadata changes less often than page reads | + +Downloaded Apple Design files are cached outside the repository by default. Set `APPLE_DOCS_MCP_CACHE_DIR` to choose a cache directory for downloaded resource files exposed through MCP `resources/list` and `resources/read`. ## 📦 WWDC Data @@ -555,4 +577,4 @@ Search Apple Developer Documentation | iOS Development | macOS Development | Swi [Report Issues](https://github.com/kimsungwhee/apple-docs-mcp/issues) • [Request Features](https://github.com/kimsungwhee/apple-docs-mcp/issues/new) • [Documentation](https://github.com/kimsungwhee/apple-docs-mcp) - \ No newline at end of file + diff --git a/src/__tests__/http-client-headers-integration.test.ts b/src/__tests__/http-client-headers-integration.test.ts index e5e6626..ae95f98 100644 --- a/src/__tests__/http-client-headers-integration.test.ts +++ b/src/__tests__/http-client-headers-integration.test.ts @@ -70,6 +70,15 @@ describe('HTTP Client Headers Integration', () => { expect(options.headers['User-Agent']).toBe('Custom-Agent/1.0'); expect(options.headers['Custom-Header']).toBe('custom-value'); }); + + test('should pass redirect handling options to fetch', async () => { + await httpClient.get('https://example.com/api', { + redirect: 'manual', + }); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.redirect).toBe('manual'); + }); }); describe('getText with generated headers', () => { @@ -232,4 +241,4 @@ describe('HTTP Client Headers Integration', () => { expect(options.headers).toHaveProperty('Accept-Encoding'); }); }); -}); \ No newline at end of file +}); diff --git a/src/index.ts b/src/index.ts index dc4577f..dad03d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,13 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, + type CallToolResult, +} from '@modelcontextprotocol/sdk/types.js'; import { parseSearchResults } from './tools/search-parser.js'; import { fetchAppleDocJson } from './tools/doc-fetcher.js'; import { handleListTechnologies } from './tools/list-technologies.js'; @@ -16,8 +22,18 @@ import { handleFindSimilarApis } from './tools/find-similar-apis.js'; import { handleGetDocumentationUpdates } from './tools/get-documentation-updates.js'; import { handleGetTechnologyOverviews } from './tools/get-technology-overviews.js'; import { handleGetSampleCode } from './tools/get-sample-code.js'; +import { + handleDownloadAppleDesignResource, + handleGetAppleDesignContent, + handleGetAppleDesignExamples, + handleListAppleDesignResources, + handleSearchAppleDesignDocs, + listCachedDesignResources, + readCachedDesignResource, +} from './tools/design-docs.js'; import { APPLE_URLS } from './utils/constants.js'; -import { isValidAppleDeveloperUrl } from './utils/url-converter.js'; +import type { AppError } from './types/error.js'; +import { isAppleDesignUrl, isValidAppleDeveloperUrl } from './utils/url-converter.js'; import { validateInput, ErrorType, createStandardErrorResponse, createToolErrorResponse } from './utils/error-handler.js'; import { httpClient } from './utils/http-client.js'; import { preloadPopularFrameworks } from './utils/preloader.js'; @@ -25,6 +41,15 @@ import { warmUpCaches, schedulePeriodicCacheRefresh } from './utils/cache-warmer import { logger } from './utils/logger.js'; import { API_LIMITS } from './utils/constants.js'; +function isAppError(error: unknown): error is AppError { + return ( + typeof error === 'object' + && error !== null + && 'type' in error + && 'message' in error + ); +} + export default class AppleDeveloperDocsMCPServer { private server: Server; @@ -34,7 +59,7 @@ export default class AppleDeveloperDocsMCPServer { private async handleAsyncOperation( operation: () => Promise, operationName: string, - ): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> { + ): Promise { try { const result = await operation(); return { @@ -48,9 +73,23 @@ export default class AppleDeveloperDocsMCPServer { } catch (error) { // If error is already an AppError, use tool-specific suggestions if (error && typeof error === 'object' && 'type' in error) { - return createToolErrorResponse(error as any, operationName); + return createToolErrorResponse(error as any, operationName) as CallToolResult; + } + return createStandardErrorResponse(error, operationName) as CallToolResult; + } + } + + private async handleToolResultOperation( + operation: () => Promise, + operationName: string, + ): Promise { + try { + return await operation(); + } catch (error) { + if (isAppError(error)) { + return createToolErrorResponse(error, operationName) as CallToolResult; } - return createStandardErrorResponse(error, operationName); + return createStandardErrorResponse(error, operationName) as CallToolResult; } } @@ -63,11 +102,13 @@ export default class AppleDeveloperDocsMCPServer { { capabilities: { tools: {}, + resources: {}, }, }, ); this.setupTools(); + this.setupResources(); this.setupErrorHandling(); } @@ -107,6 +148,16 @@ export default class AppleDeveloperDocsMCPServer { }); } + private setupResources() { + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + return await listCachedDesignResources(); + }); + + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + return await readCachedDesignResource(request.params.uri); + }); + } + public async searchAppleDocs(query: string, type: string = 'all') { try { // 输入验证 @@ -155,6 +206,10 @@ export default class AppleDeveloperDocsMCPServer { }, 'get_apple_doc_content'); } + if (isAppleDesignUrl(url)) { + return await this.getAppleDesignContent(url); + } + // fetchAppleDocJson 已经返回正确的MCP响应格式,直接返回 return await fetchAppleDocJson(url, { includeRelatedApis, @@ -182,6 +237,81 @@ export default class AppleDeveloperDocsMCPServer { ); } + public async searchAppleDesignDocs( + query: string, + contentType: 'all' | 'hig' | 'resource' | 'page' = 'all', + platform: string = 'all', + limit: number = 20, + ) { + return this.handleToolResultOperation( + () => handleSearchAppleDesignDocs({ + query, + contentType, + platform, + limit, + }), + 'search_apple_design_docs', + ); + } + + public async getAppleDesignContent(url: string) { + return this.handleToolResultOperation( + () => handleGetAppleDesignContent({ url }), + 'get_apple_design_content', + ); + } + + public async listAppleDesignResources( + category?: string, + platform?: string, + format?: string, + searchQuery?: string, + limit: number = 50, + ) { + return this.handleToolResultOperation( + () => handleListAppleDesignResources({ + category, + platform, + format, + searchQuery, + limit, + }), + 'list_apple_design_resources', + ); + } + + public async downloadAppleDesignResource( + resourceId?: string, + url?: string, + maxBytes?: number, + ) { + return this.handleToolResultOperation( + () => handleDownloadAppleDesignResource({ + resourceId, + url, + maxBytes, + }), + 'download_apple_design_resource', + ); + } + + public async getAppleDesignExamples( + url?: string, + resourceId?: string, + query?: string, + limit: number = 3, + ) { + return this.handleToolResultOperation( + () => handleGetAppleDesignExamples({ + url, + resourceId, + query, + limit, + }), + 'get_apple_design_examples', + ); + } + public async searchFrameworkSymbols(framework: string, symbolType: string = 'all', namePattern?: string, language: string = 'swift', limit: number = API_LIMITS.DEFAULT_FRAMEWORK_SYMBOLS_LIMIT) { return this.handleAsyncOperation( () => searchFrameworkSymbols(framework, symbolType, namePattern, language, limit), @@ -316,4 +446,4 @@ if (process.env.NODE_ENV !== 'test') { logger.error('Fatal error in main():', error); process.exit(1); }); -} \ No newline at end of file +} diff --git a/src/schemas/design.schema.ts b/src/schemas/design.schema.ts new file mode 100644 index 0000000..8794296 --- /dev/null +++ b/src/schemas/design.schema.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +export const searchAppleDesignDocsSchema = z.object({ + query: z.string().describe('Search query for Apple Design and HIG documentation'), + contentType: z.enum(['all', 'hig', 'resource', 'page']).default('all') + .describe('Type of Apple Design content to search'), + platform: z.string().default('all') + .describe('Platform filter such as iOS, iPadOS, macOS, watchOS, tvOS, or visionOS'), + limit: z.number().int().min(1).max(50).default(20) + .describe('Maximum number of results to return'), +}); + +export const getAppleDesignContentSchema = z.object({ + url: z.url().describe('Apple Design URL to read'), +}); + +export const listAppleDesignResourcesSchema = z.object({ + category: z.string().optional().describe('Resource category filter'), + platform: z.string().optional().describe('Platform or subsection filter'), + format: z.string().optional().describe('Resource format filter such as figma, sketch, dmg, or zip'), + searchQuery: z.string().optional().describe('Search query for resource titles, notes, and labels'), + limit: z.number().int().min(1).max(100).default(50) + .describe('Maximum number of resources to return'), +}); + +export const downloadAppleDesignResourceSchema = z.object({ + resourceId: z.string().optional().describe('Resource ID returned by list_apple_design_resources'), + url: z.url().optional().describe('Direct Apple-hosted resource URL to download'), + maxBytes: z.number().int().min(1).max(250 * 1024 * 1024).optional() + .describe('Maximum download size in bytes'), +}); + +export const getAppleDesignExamplesSchema = z.object({ + url: z.url().optional().describe('Apple Design, HIG, preview, or image URL'), + resourceId: z.string().optional().describe('Resource ID returned by list_apple_design_resources'), + query: z.string().optional().describe('Search query for examples and thumbnails'), + limit: z.number().int().min(1).max(10).default(3) + .describe('Maximum number of image examples to return'), +}); diff --git a/src/schemas/index.ts b/src/schemas/index.ts index caba501..9b2f2b4 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -8,4 +8,11 @@ export { getPlatformCompatibilitySchema } from './platform-compatibility.schema. export { findSimilarApisSchema } from './similar-apis.schema.js'; export { getDocumentationUpdatesSchema } from './documentation-updates.schema.js'; export { getTechnologyOverviewsSchema } from './technology-overviews.schema.js'; -export { getSampleCodeSchema } from './sample-code.schema.js'; \ No newline at end of file +export { getSampleCodeSchema } from './sample-code.schema.js'; +export { + searchAppleDesignDocsSchema, + getAppleDesignContentSchema, + listAppleDesignResourcesSchema, + downloadAppleDesignResourceSchema, + getAppleDesignExamplesSchema, +} from './design.schema.js'; diff --git a/src/tools/definitions.ts b/src/tools/definitions.ts index f47b834..d5dbbfa 100644 --- a/src/tools/definitions.ts +++ b/src/tools/definitions.ts @@ -66,6 +66,153 @@ export const toolDefinitions: Tool[] = [ readOnlyHint: true, }, }, + { + name: 'search_apple_design_docs', + description: 'Search Apple Design pages, Human Interface Guidelines JSON, and Design Resources catalog entries. Use this for HIG guidance, design resources, templates, fonts, product bezels, and Apple Design overview pages.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query for Apple Design or HIG content. Examples: "layout", "iOS templates", "SF Pro", "visionOS".', + }, + contentType: { + type: 'string', + enum: ['all', 'hig', 'resource', 'page'], + description: 'Content filter. Use "hig" for Human Interface Guidelines, "resource" for Design Resources, "page" for Apple Design HTML pages. Default: "all".', + }, + platform: { + type: 'string', + description: 'Platform filter such as iOS, iPadOS, macOS, watchOS, tvOS, or visionOS. Default: "all".', + }, + limit: { + type: 'number', + description: 'Maximum number of results. Default: 20.', + minimum: 1, + maximum: 50, + }, + }, + required: ['query'], + }, + annotations: { + title: 'Search Apple Design Docs', + readOnlyHint: true, + }, + }, + { + name: 'get_apple_design_content', + description: 'Read Apple Design and Human Interface Guidelines URLs. HIG pages use Apple JSON data when available, and other /design/ pages use HTML parsing.', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'Apple Design URL. Examples: "https://developer.apple.com/design/human-interface-guidelines/layout" or "https://developer.apple.com/design/resources/".', + }, + }, + required: ['url'], + }, + annotations: { + title: 'Get Apple Design Content', + readOnlyHint: true, + }, + }, + { + name: 'list_apple_design_resources', + description: 'List Apple Design Resources catalog entries with stable resource IDs, categories, platforms, formats, notes, preview image URLs, and download URLs.', + inputSchema: { + type: 'object', + properties: { + category: { + type: 'string', + description: 'Optional category filter such as "Design templates", "Fonts", "Tools", or "Product bezels".', + }, + platform: { + type: 'string', + description: 'Optional platform or subsection filter such as "iOS", "macOS", or "visionOS".', + }, + format: { + type: 'string', + description: 'Optional format filter such as "figma", "sketch", "dmg", "zip", or "pdf".', + }, + searchQuery: { + type: 'string', + description: 'Optional search query for titles, labels, notes, and categories.', + }, + limit: { + type: 'number', + description: 'Maximum number of resources. Default: 50.', + minimum: 1, + maximum: 100, + }, + }, + required: [], + }, + annotations: { + title: 'List Apple Design Resources', + readOnlyHint: true, + }, + }, + { + name: 'download_apple_design_resource', + description: 'Download a selected direct Apple Design resource into the local MCP cache and return MCP resource links. External Figma, Sketch web, and sketch:// links are reported as metadata and are not browser-authenticated downloads.', + inputSchema: { + type: 'object', + properties: { + resourceId: { + type: 'string', + description: 'Stable resource ID returned by list_apple_design_resources.', + }, + url: { + type: 'string', + description: 'Direct Apple-hosted resource URL to download. Allowed hosts include developer.apple.com, docs-assets.developer.apple.com, devimages-cdn.apple.com, and itunespartner.apple.com.', + }, + maxBytes: { + type: 'number', + description: 'Optional maximum download size in bytes. Default: 52428800.', + minimum: 1, + maximum: 262144000, + }, + }, + required: [], + }, + annotations: { + title: 'Download Apple Design Resource', + readOnlyHint: false, + }, + }, + { + name: 'get_apple_design_examples', + description: 'Return Apple Design visual examples as MCP image content blocks with base64 data and MIME type. Supports HIG images, resource thumbnails, and direct image asset URLs.', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'Apple Design URL, HIG URL, resource preview URL, or direct image URL.', + }, + resourceId: { + type: 'string', + description: 'Stable resource ID returned by list_apple_design_resources.', + }, + query: { + type: 'string', + description: 'Search query for resource thumbnails.', + }, + limit: { + type: 'number', + description: 'Maximum number of image blocks. Default: 3.', + minimum: 1, + maximum: 10, + }, + }, + required: [], + }, + annotations: { + title: 'Get Apple Design Examples', + readOnlyHint: true, + }, + }, { name: 'list_technologies', description: 'Browse all Apple technologies and frameworks by category. Essential for discovering available frameworks and understanding Apple\'s technology ecosystem. Use this when: exploring what\'s available, finding framework identifiers for search_framework_symbols, checking beta status.', @@ -530,4 +677,4 @@ export const toolDefinitions: Tool[] = [ readOnlyHint: true, }, }, -]; \ No newline at end of file +]; diff --git a/src/tools/design-docs.ts b/src/tools/design-docs.ts new file mode 100644 index 0000000..bf7fe09 --- /dev/null +++ b/src/tools/design-docs.ts @@ -0,0 +1,2500 @@ +import type { + CallToolResult, + ContentBlock, + ImageContent, + ListResourcesResult, + ReadResourceResult, + Resource, + ResourceLink, +} from '@modelcontextprotocol/sdk/types.js'; +import * as cheerio from 'cheerio'; +import { createHash, randomUUID } from 'node:crypto'; +import { mkdtempSync, type Dirent } from 'node:fs'; +import { mkdir, open, readFile, readdir, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { designContentCache, designResourcesCache } from '../utils/cache.js'; +import { APPLE_URLS, REQUEST_CONFIG } from '../utils/constants.js'; +import { httpClient } from '../utils/http-client.js'; +import { convertToDesignJsonApiUrl } from '../utils/url-converter.js'; + +const APPLE_DESIGN_DOWNLOAD_HOSTS = new Set([ + 'developer.apple.com', + 'docs-assets.developer.apple.com', + 'devimages-cdn.apple.com', + 'itunespartner.apple.com', +]); +const DEFAULT_DOWNLOAD_MAX_BYTES = 50 * 1024 * 1024; +const DEFAULT_DOWNLOAD_CACHE_MAX_BYTES = 1024 * 1024 * 1024; +const DEFAULT_PREVIEW_IMAGE_MAX_BYTES = 10 * 1024 * 1024; +const DEFAULT_INLINE_DOWNLOAD_IMAGE_MAX_BYTES = DEFAULT_PREVIEW_IMAGE_MAX_BYTES; +const DEFAULT_DESIGN_CONTENT_MAX_BYTES = 20 * 1024 * 1024; +const MAX_APPLE_DESIGN_REDIRECTS = 5; +const DIRECT_RESOURCE_EXTENSIONS = new Set([ + '.dmg', + '.fig', + '.gif', + '.jpeg', + '.jpg', + '.pdf', + '.png', + '.sketch', + '.svg', + '.webp', + '.zip', +]); +const IMAGE_MIME_PREFIX = 'image/'; +const RESOURCE_URI_PREFIX = 'apple-design://cache/'; + +export interface DesignResourceCatalogEntry { + resourceId: string; + category: string; + platform?: string; + title: string; + downloadLabel: string; + downloadUrl: string; + format: string; + note?: string; + previewImageUrl?: string; + relatedGuidelineLinks: string[]; +} + +interface CachedDesignResource { + uri: string; + name: string; + title: string; + description: string; + mimeType: string; + filePath: string; + sourceUrl: string; + size: number; +} + +interface DesignSearchResult { + title: string; + type: 'hig' | 'resource' | 'page'; + url: string; + description?: string; + resourceId?: string; + platform?: string; + format?: string; + previewImageUrl?: string; +} + +interface DesignImageCandidate { + url: string; + alt?: string; + strict?: boolean; +} + +interface InFlightDesignDownload { + byteLimit: number; + promise: Promise; +} + +type AppleDesignUrlValidator = (url: string, description: string) => void; + +export interface SearchAppleDesignDocsArgs { + query: string; + contentType?: 'all' | 'hig' | 'resource' | 'page'; + platform?: string; + limit?: number; +} + +export interface ListAppleDesignResourcesArgs { + category?: string; + platform?: string; + format?: string; + searchQuery?: string; + limit?: number; +} + +export interface DownloadAppleDesignResourceArgs { + resourceId?: string; + url?: string; + maxBytes?: number; +} + +export interface GetAppleDesignExamplesArgs { + url?: string; + resourceId?: string; + query?: string; + limit?: number; +} + +const cachedResourcesByUri = new Map(); +const cachedResourcesBySourceUrl = new Map(); +const inFlightDownloadsBySourceUrl = new Map(); +const resourceCatalogById = new Map(); +let defaultDesignCacheDirectory: string | undefined; +let designCacheWriteQueue: Promise = Promise.resolve(); + +/** + * Format Apple Design HIG JSON as readable Markdown. + * @param jsonData Apple Design JSON payload. + * @param originalUrl Source Apple Design URL. + * @returns Markdown content. + */ +export function formatAppleDesignDocument(jsonData: unknown, originalUrl: string): string { + const documentRecord = asRecord(jsonData); + if (!documentRecord) { + return `# Apple Design\n\nUnable to parse Apple Design content.\n\n---\n\n[View on Apple Developer](${originalUrl})`; + } + + const references = getRecordMap(documentRecord.references); + const metadata = asRecord(documentRecord.metadata); + const title = getString(metadata, 'title') ?? getString(documentRecord, 'title') ?? 'Apple Design'; + let content = `# ${title}\n\n`; + + const abstractText = renderInlineCollection( + getArray(documentRecord.abstract).length > 0 + ? getArray(documentRecord.abstract) + : getArray(metadata?.abstract), + references, + originalUrl, + ); + if (abstractText) { + content += `${abstractText}\n\n`; + } + + const supportedPlatforms = getSupportedPlatforms(documentRecord); + if (supportedPlatforms.length > 0) { + content += '## Supported Platforms\n\n'; + content += `${supportedPlatforms.map(platform => `- ${platform}`).join('\n')}\n\n`; + } + + const alertText = getCustomMetadataString(documentRecord, 'alert-text'); + const alertDate = getCustomMetadataString(documentRecord, 'alert-date'); + if (alertText) { + content += '## Alert\n\n'; + if (alertDate) { + content += `**${alertDate}**: `; + } + content += `${alertText}\n\n`; + } + + const primarySections = getRecordArray(documentRecord.primaryContentSections); + for (const section of primarySections) { + content += formatDesignContentSection(section, references, originalUrl, 2); + } + + const topicSections = getRecordArray(documentRecord.topicSections); + if (topicSections.length > 0) { + content += formatTopicSections(topicSections, references, originalUrl); + } + + const secondarySections = getRecordArray(documentRecord.sections); + for (const section of secondarySections) { + content += formatSecondarySection(section, references, originalUrl); + } + + content += `---\n\n[View on Apple Developer](${originalUrl})`; + return content; +} + +/** + * Parse static Apple Design HTML pages as Markdown. + * @param html Apple Design HTML. + * @param sourceUrl Source URL. + * @returns Markdown content. + */ +export function parseAppleDesignHtmlPage(html: string, sourceUrl: string): string { + const $ = cheerio.load(html); + const mainElement = $('main').first(); + const rootElement = mainElement.length > 0 ? mainElement : $('body').first(); + const title = normalizeWhitespace(rootElement.find('h1').first().text()) + || normalizeWhitespace($('title').first().text()) + || 'Apple Design'; + let content = `# ${title}\n\n`; + + rootElement.find('p').each((_index, element) => { + const paragraph = normalizeWhitespace($(element).text()); + if (paragraph) { + content += `${paragraph}\n\n`; + } + }); + + const links = new Map(); + rootElement.find('a[href]').each((_index, element) => { + const linkElement = $(element); + const href = linkElement.attr('href'); + const normalizedUrl = normalizeUrl(href, sourceUrl); + if (!normalizedUrl) { + return; + } + + const parsedUrl = safeUrl(normalizedUrl); + if (!parsedUrl || parsedUrl.hostname !== 'developer.apple.com' || !parsedUrl.pathname.startsWith('/design/')) { + return; + } + + const linkTitle = normalizeWhitespace( + linkElement.find('h2, h3, h4, h5').first().text(), + ) || normalizeWhitespace(linkElement.text()); + if (!linkTitle) { + return; + } + + const description = normalizeWhitespace(linkElement.find('p').first().text()); + links.set(normalizedUrl, { + title: linkTitle, + description: description || undefined, + }); + }); + + if (links.size > 0) { + content += '## Links\n\n'; + for (const [url, link] of links.entries()) { + content += `- [${link.title}](${url})`; + if (link.description) { + content += `: ${link.description}`; + } + content += '\n'; + } + content += '\n'; + } + + content += `---\n\n[View on Apple Developer](${sourceUrl})`; + return content; +} + +/** + * Parse Apple Design Resources HTML into catalog entries. + * @param html Apple Design Resources HTML. + * @param sourceUrl Source URL. + * @returns Resource catalog entries. + */ +export function parseDesignResourcesHtml( + html: string, + sourceUrl: string, +): DesignResourceCatalogEntry[] { + const $ = cheerio.load(html); + const entries: DesignResourceCatalogEntry[] = []; + const seenResourceIds = new Map(); + const sections = $('section.section-download'); + const sectionElements = sections.length > 0 ? sections : $('section'); + + sectionElements.each((_sectionIndex, sectionElement) => { + const section = $(sectionElement); + const category = normalizeWhitespace(section.find('h2').first().text()) || 'Design Resources'; + let currentPlatform = ''; + + section.children().each((_childIndex, childElement) => { + const child = $(childElement); + if (child.is('h3, h4')) { + currentPlatform = normalizeWhitespace(child.text()); + } + + const itemElements = child.hasClass('grid-item') ? child : child.find('.grid-item'); + itemElements.each((_itemIndex, itemElement) => { + const item = $(itemElement); + const platform = currentPlatform || findNearestHeadingText($, item); + const itemEntries = parseDesignResourceItem( + $, + item, + category, + platform, + sourceUrl, + seenResourceIds, + ); + entries.push(...itemEntries); + }); + }); + }); + + return entries; +} + +/** + * Search Apple Design, HIG, and Design Resources content. + * @param args Search arguments. + * @returns MCP tool result. + */ +export async function handleSearchAppleDesignDocs( + args: SearchAppleDesignDocsArgs, +): Promise { + const query = args.query.trim(); + const contentType = args.contentType ?? 'all'; + const platform = args.platform ?? 'all'; + const limit = args.limit ?? 20; + + const results = await collectDesignSearchResults(query, contentType, platform, limit); + return { + content: [ + createTextContent(formatDesignSearchResults(results, query)), + ], + }; +} + +/** + * Read an Apple Design URL. + * @param args Content arguments. + * @returns MCP tool result. + */ +export async function handleGetAppleDesignContent( + args: { url: string } | string, +): Promise { + const url = typeof args === 'string' ? args : args.url; + validateAppleDesignContentUrl(url); + const content = await fetchAppleDesignContent(url); + return { + content: [ + createTextContent(content), + ], + }; +} + +/** + * List Apple Design Resources catalog entries. + * @param args List arguments. + * @returns MCP tool result. + */ +export async function handleListAppleDesignResources( + args: ListAppleDesignResourcesArgs = {}, +): Promise { + const resources = await getDesignResourcesCatalog(); + const filteredResources = filterDesignResources(resources, args); + const limitedResources = filteredResources.slice(0, args.limit ?? 50); + + return { + content: [ + createTextContent(formatDesignResources(limitedResources)), + ], + }; +} + +/** + * Download a direct Apple Design resource into the local MCP cache. + * @param args Download arguments. + * @returns MCP tool result. + */ +export async function handleDownloadAppleDesignResource( + args: DownloadAppleDesignResourceArgs, +): Promise { + const resource = await resolveDownloadResource(args); + if (!resource.downloadUrl) { + throw new Error('A resourceId or direct URL is required.'); + } + + if (!isDirectAppleDownloadUrl(resource.downloadUrl)) { + if (!args.resourceId) { + validateDownloadUrl(resource.downloadUrl); + } + + return { + content: [ + createTextContent(formatNonDownloadableResource(resource)), + ], + }; + } + + validateDownloadUrl(resource.downloadUrl); + const cachedResource = await downloadDesignResource(resource, args.maxBytes); + return { + content: await createDownloadedResourceContent(cachedResource), + }; +} + +/** + * Return visual examples as MCP image content blocks. + * @param args Example lookup arguments. + * @returns MCP tool result. + */ +export async function handleGetAppleDesignExamples( + args: GetAppleDesignExamplesArgs = {}, +): Promise { + const limit = args.limit ?? 3; + const candidates = await collectImageCandidates(args); + const uniqueCandidates = dedupeImageCandidates(candidates); + const imageContentBlocks: ContentBlock[] = []; + + for (const candidate of uniqueCandidates) { + if (imageContentBlocks.length >= limit) { + break; + } + + let imageContent: ImageContent | null; + try { + imageContent = await fetchImageContent(candidate.url); + } catch (error) { + if (candidate.strict) { + throw error; + } + continue; + } + + if (imageContent) { + imageContentBlocks.push(imageContent); + } + } + + if (imageContentBlocks.length === 0) { + return { + content: [ + createTextContent('Apple Design Examples\n\nNo directly fetchable image examples were found.'), + ], + }; + } + + return { + content: [ + createTextContent(`Apple Design Examples\n\nFound ${imageContentBlocks.length} image example${imageContentBlocks.length === 1 ? '' : 's'}.`), + ...imageContentBlocks, + ], + }; +} + +/** + * List cached downloaded resources for MCP resources/list. + * @returns MCP resources/list result. + */ +export async function listCachedDesignResources(): Promise { + const resources: Resource[] = []; + for (const cachedResource of cachedResourcesByUri.values()) { + resources.push({ + uri: cachedResource.uri, + name: cachedResource.name, + title: cachedResource.title, + description: cachedResource.description, + mimeType: cachedResource.mimeType, + }); + } + + return { resources }; +} + +/** + * Read cached downloaded resources for MCP resources/read. + * @param uri Resource URI. + * @returns MCP resources/read result. + */ +export async function readCachedDesignResource(uri: string): Promise { + const cachedResource = await getCachedResourceForRead(uri); + const data = await readFile(cachedResource.filePath); + return { + contents: [ + { + uri: cachedResource.uri, + mimeType: cachedResource.mimeType, + blob: data.toString('base64'), + }, + ], + }; +} + +async function getCachedResourceForRead(uri: string): Promise { + const cachedResource = cachedResourcesByUri.get(uri); + if (cachedResource) { + return cachedResource; + } + + const persistedResource = await resolvePersistedCachedResource(uri); + if (persistedResource) { + cachedResourcesByUri.set(uri, persistedResource); + return persistedResource; + } + + throw new Error(`Unknown Apple Design resource URI: ${uri}`); +} + +async function resolvePersistedCachedResource(uri: string): Promise { + const parsedUri = parseCachedResourceUri(uri); + if (!parsedUri) { + return undefined; + } + + const cacheDirectory = path.resolve(getDesignCacheDirectory()); + const filePath = path.resolve(cacheDirectory, parsedUri.cacheFilename); + const relativePath = path.relative(cacheDirectory, filePath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return undefined; + } + + let fileStats; + try { + fileStats = await stat(filePath); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return undefined; + } + throw error; + } + + if (!fileStats.isFile()) { + return undefined; + } + + const name = getNameFromCacheFilename(parsedUri.hash, parsedUri.cacheFilename); + return { + uri, + name, + title: name, + description: 'Persisted Apple Design resource', + mimeType: detectMimeType(undefined, parsedUri.cacheFilename), + filePath, + sourceUrl: '', + size: fileStats.size, + }; +} + +function parseCachedResourceUri(uri: string): { hash: string; cacheFilename: string } | undefined { + if (!uri.startsWith(RESOURCE_URI_PREFIX)) { + return undefined; + } + + const suffix = uri.slice(RESOURCE_URI_PREFIX.length); + const parts = suffix.split('/'); + if (parts.length !== 2) { + return undefined; + } + + const [hash, cacheFilename] = parts; + if (!hash || !/^[a-f0-9]{64}$/.test(hash)) { + return undefined; + } + + if ( + !cacheFilename + || cacheFilename.includes('\\') + || cacheFilename.includes('\0') + || cacheFilename === '.' + || cacheFilename === '..' + || cacheFilename !== path.basename(cacheFilename) + || !cacheFilename.startsWith(`${hash}-`) + ) { + return undefined; + } + + return { + hash, + cacheFilename, + }; +} + +function getNameFromCacheFilename(hash: string, cacheFilename: string): string { + const filenameWithUuid = cacheFilename.slice(`${hash}-`.length); + if (filenameWithUuid.length > 37 && filenameWithUuid[36] === '-') { + return filenameWithUuid.slice(37); + } + + return cacheFilename; +} + +async function cancelResponseBody(response: Response): Promise { + if (!response.body) { + return; + } + + await response.body.cancel().catch(() => undefined); +} + +/** + * Clear process-local Design download state for tests. + * @returns Nothing. + */ +export function clearDesignResourceCacheForTesting(): void { + designContentCache.clear(); + designResourcesCache.clear(); + cachedResourcesByUri.clear(); + cachedResourcesBySourceUrl.clear(); + inFlightDownloadsBySourceUrl.clear(); + resourceCatalogById.clear(); + designCacheWriteQueue = Promise.resolve(); +} + +async function fetchAppleDesignContent(url: string): Promise { + const cacheKey = `design-content:${url}`; + const cachedContent = designContentCache.get(cacheKey); + if (cachedContent) { + return cachedContent; + } + + const jsonUrl = convertToDesignJsonApiUrl(url); + let content: string; + if (jsonUrl) { + const jsonData = await fetchAppleDesignJson(jsonUrl); + content = formatAppleDesignDocument(jsonData, url); + } else { + const { html, finalUrl } = await fetchAppleDesignHtml(url); + if (isDesignResourcesUrl(finalUrl)) { + const resources = parseDesignResourcesHtml(html, finalUrl); + updateResourceCatalogIndex(resources); + content = `${parseAppleDesignHtmlPage(html, finalUrl)}\n\n${formatDesignResources(resources)}`; + } else { + content = parseAppleDesignHtmlPage(html, finalUrl); + } + } + + designContentCache.set(cacheKey, content); + return content; +} + +async function getDesignResourcesCatalog(): Promise { + const cachedResources = designResourcesCache.get('design-resources'); + if (cachedResources) { + updateResourceCatalogIndex(cachedResources); + return cachedResources; + } + + const { html, finalUrl } = await fetchAppleDesignHtml(APPLE_URLS.DESIGN_RESOURCES); + const resources = parseDesignResourcesHtml(html, finalUrl); + updateResourceCatalogIndex(resources); + designResourcesCache.set('design-resources', resources); + return resources; +} + +async function collectDesignSearchResults( + query: string, + contentType: 'all' | 'hig' | 'resource' | 'page', + platform: string, + limit: number, +): Promise { + const normalizedQuery = query.toLowerCase(); + const results: DesignSearchResult[] = []; + + if (contentType === 'all' || contentType === 'hig') { + const higResults = await collectHigSearchResults(normalizedQuery, platform); + results.push(...higResults); + } + + if (contentType === 'all' || contentType === 'resource') { + const resources = await getDesignResourcesCatalog(); + for (const resource of resources) { + if (!resourceMatchesPlatform(resource, platform)) { + continue; + } + const searchableText = [ + resource.title, + resource.category, + resource.platform, + resource.downloadLabel, + resource.format, + resource.note, + ].filter(Boolean).join(' ').toLowerCase(); + + if (!searchableText.includes(normalizedQuery)) { + continue; + } + + results.push({ + title: resource.title, + type: 'resource', + url: resource.downloadUrl, + description: resource.note, + resourceId: resource.resourceId, + platform: resource.platform, + format: resource.format, + previewImageUrl: resource.previewImageUrl, + }); + } + } + + if (contentType === 'all' || contentType === 'page') { + const pageResults = getStaticDesignPageResults(normalizedQuery); + results.push(...pageResults); + } + + return results.slice(0, limit); +} + +async function collectHigSearchResults(query: string, platform: string): Promise { + try { + const rootJson = await fetchAppleDesignJson(APPLE_URLS.DESIGN_HIG_JSON); + const documentRecord = asRecord(rootJson); + if (!documentRecord) { + return []; + } + + const references = getRecordMap(documentRecord.references); + const results: DesignSearchResult[] = []; + const metadata = asRecord(documentRecord.metadata); + const rootTitle = getString(metadata, 'title') ?? 'Human Interface Guidelines'; + const rootAbstract = renderInlineCollection(getArray(documentRecord.abstract), references, APPLE_URLS.DESIGN); + if ( + `${rootTitle} ${rootAbstract}`.toLowerCase().includes(query) + && designRecordMatchesPlatform(documentRecord, platform, `${rootTitle} ${rootAbstract}`) + ) { + results.push({ + title: rootTitle, + type: 'hig', + url: 'https://developer.apple.com/design/human-interface-guidelines', + description: rootAbstract, + }); + } + + for (const reference of references.values()) { + const title = getString(reference, 'title'); + const url = normalizeReferenceUrl(reference, APPLE_URLS.DESIGN); + if (!title || !url || !url.includes('/design/human-interface-guidelines')) { + continue; + } + + const description = renderInlineCollection(getArray(reference.abstract), references, APPLE_URLS.DESIGN); + if ( + `${title} ${description}`.toLowerCase().includes(query) + && designRecordMatchesPlatform(reference, platform, `${title} ${description}`) + ) { + results.push({ + title, + type: 'hig', + url, + description, + }); + } + } + + return results; + } catch { + return []; + } +} + +function getStaticDesignPageResults(query: string): DesignSearchResult[] { + const pages: DesignSearchResult[] = [ + { + title: 'Apple Design', + type: 'page', + url: APPLE_URLS.DESIGN, + description: 'Apple Design guidance, videos, resources, and Human Interface Guidelines.', + }, + { + title: 'Design Resources', + type: 'page', + url: APPLE_URLS.DESIGN_RESOURCES, + description: 'Templates, product bezels, fonts, tools, and downloadable resources.', + }, + { + title: 'What is new in Apple Design', + type: 'page', + url: 'https://developer.apple.com/design/whats-new/', + description: 'Recent Apple Design updates and platform guidance.', + }, + { + title: 'Get started with Apple Design', + type: 'page', + url: 'https://developer.apple.com/design/get-started/', + description: 'Introductory Apple Design guidance.', + }, + ]; + + return pages.filter(page => { + const searchableText = `${page.title} ${page.description ?? ''}`.toLowerCase(); + return searchableText.includes(query); + }); +} + +function formatDesignSearchResults(results: DesignSearchResult[], query: string): string { + let content = `# Apple Design Search Results\n\nQuery: "${query}"\n\n`; + if (results.length === 0) { + return `${content}No Apple Design results found.`; + } + + results.forEach((result, index) => { + content += `## ${index + 1}. ${result.title}\n\n`; + content += `- Type: ${result.type}\n`; + content += `- URL: ${result.url}\n`; + if (result.resourceId) { + content += `- Resource ID: ${result.resourceId}\n`; + } + if (result.platform) { + content += `- Platform: ${result.platform}\n`; + } + if (result.format) { + content += `- Format: ${result.format}\n`; + } + if (result.previewImageUrl) { + content += `- Preview: ${result.previewImageUrl}\n`; + } + if (result.description) { + content += `\n${result.description}\n`; + } + content += '\n'; + }); + + return content.trimEnd(); +} + +function filterDesignResources( + resources: DesignResourceCatalogEntry[], + args: ListAppleDesignResourcesArgs, +): DesignResourceCatalogEntry[] { + return resources.filter(resource => { + if (args.category && !containsText(resource.category, args.category)) { + return false; + } + if (args.platform && !containsText(resource.platform, args.platform)) { + return false; + } + if (args.format && !containsText(resource.format, args.format)) { + return false; + } + if (args.searchQuery) { + const searchableText = [ + resource.title, + resource.category, + resource.platform, + resource.downloadLabel, + resource.format, + resource.note, + ].filter(Boolean).join(' '); + if (!containsText(searchableText, args.searchQuery)) { + return false; + } + } + return true; + }); +} + +function formatDesignResources(resources: DesignResourceCatalogEntry[]): string { + let content = '# Apple Design Resources\n\n'; + if (resources.length === 0) { + return `${content}No matching Design Resources found.`; + } + + for (const resource of resources) { + content += `## ${resource.title}\n\n`; + content += `- Resource ID: ${resource.resourceId}\n`; + content += `- Category: ${resource.category}\n`; + if (resource.platform) { + content += `- Platform: ${resource.platform}\n`; + } + content += `- Format: ${resource.format}\n`; + content += `- Download Label: ${resource.downloadLabel}\n`; + content += `- Download URL: ${resource.downloadUrl}\n`; + if (resource.previewImageUrl) { + content += `- Preview Image URL: ${resource.previewImageUrl}\n`; + } + if (resource.note) { + content += `- Notes: ${resource.note}\n`; + } + if (resource.relatedGuidelineLinks.length > 0) { + content += `- Related HIG Links: ${resource.relatedGuidelineLinks.join(', ')}\n`; + } + content += '\n'; + } + + return content.trimEnd(); +} + +async function resolveDownloadResource( + args: DownloadAppleDesignResourceArgs, +): Promise { + if (args.resourceId) { + if (!resourceCatalogById.has(args.resourceId)) { + await getDesignResourcesCatalog(); + } + + const resource = resourceCatalogById.get(args.resourceId); + if (!resource) { + throw new Error(`Unknown Apple Design resourceId: ${args.resourceId}`); + } + return resource; + } + + if (!args.url) { + throw new Error('A resourceId or direct URL is required.'); + } + + return { + resourceId: `direct:${hashString(args.url).slice(0, 12)}`, + category: 'Direct URL', + title: getFilenameFromUrl(args.url), + downloadLabel: 'Download', + downloadUrl: args.url, + format: inferFormat(args.url, 'Download'), + relatedGuidelineLinks: [], + }; +} + +async function downloadDesignResource( + resource: DesignResourceCatalogEntry, + maxBytes: number | undefined, +): Promise { + const normalizedSourceUrl = resource.downloadUrl; + const byteLimit = maxBytes ?? DEFAULT_DOWNLOAD_MAX_BYTES; + const cachedResource = cachedResourcesBySourceUrl.get(normalizedSourceUrl); + if (cachedResource) { + if (cachedResource.size > byteLimit) { + throw new Error(`Apple Design resource exceeds the ${byteLimit} byte download limit.`); + } + return cachedResource; + } + + const inFlightDownload = inFlightDownloadsBySourceUrl.get(normalizedSourceUrl); + if (inFlightDownload && inFlightDownload.byteLimit >= byteLimit) { + const downloadedResource = await inFlightDownload.promise; + if (downloadedResource.size > byteLimit) { + throw new Error(`Apple Design resource exceeds the ${byteLimit} byte download limit.`); + } + return downloadedResource; + } + + const downloadPromise = downloadDesignResourceToCache(resource, byteLimit, normalizedSourceUrl); + inFlightDownloadsBySourceUrl.set(normalizedSourceUrl, { + byteLimit, + promise: downloadPromise, + }); + + try { + return await downloadPromise; + } finally { + if (inFlightDownloadsBySourceUrl.get(normalizedSourceUrl)?.promise === downloadPromise) { + inFlightDownloadsBySourceUrl.delete(normalizedSourceUrl); + } + } +} + +async function downloadDesignResourceToCache( + resource: DesignResourceCatalogEntry, + byteLimit: number, + normalizedSourceUrl: string, +): Promise { + const { + response, + finalUrl, + } = await fetchAppleDesignAssetResponse(normalizedSourceUrl, 'Apple Design resource', { + headers: { + Accept: '*/*', + }, + }); + + const data = await readLimitedResponseBytes( + response, + byteLimit, + 'Apple Design resource', + ); + + const mimeType = detectMimeType(response.headers.get('content-type'), finalUrl); + const hash = hashBuffer(data); + const filename = sanitizeFilename(getFilenameFromUrl(finalUrl)); + const cacheDirectory = getDesignCacheDirectory(); + const { + cacheFilename, + filePath, + } = await withDesignCacheWriteLock(async () => { + await mkdir(cacheDirectory, { recursive: true, mode: 0o700 }); + await assertDesignCacheCapacity(cacheDirectory, data.length); + return await writeExclusiveDesignCacheFile(cacheDirectory, hash, filename, data); + }); + + const uri = `${RESOURCE_URI_PREFIX}${hash}/${cacheFilename}`; + const newCachedResource: CachedDesignResource = { + uri, + name: filename, + title: resource.title, + description: `${resource.category}${resource.platform ? `, ${resource.platform}` : ''}`, + mimeType, + filePath, + sourceUrl: normalizedSourceUrl, + size: data.length, + }; + + cachedResourcesByUri.set(uri, newCachedResource); + cachedResourcesBySourceUrl.set(normalizedSourceUrl, newCachedResource); + return newCachedResource; +} + +async function createDownloadedResourceContent( + cachedResource: CachedDesignResource, +): Promise { + const content: ContentBlock[] = [ + createTextContent( + `Downloaded Apple Design resource: ${cachedResource.name}\n\n` + + `- URI: ${cachedResource.uri}\n` + + `- MIME Type: ${cachedResource.mimeType}\n` + + `- Size: ${cachedResource.size} bytes`, + ), + ]; + + if ( + isImageMimeType(cachedResource.mimeType) + && cachedResource.size <= DEFAULT_INLINE_DOWNLOAD_IMAGE_MAX_BYTES + ) { + const data = await readFile(cachedResource.filePath); + content.push({ + type: 'image', + data: data.toString('base64'), + mimeType: cachedResource.mimeType, + }); + } + + content.push(createResourceLink(cachedResource)); + return content; +} + +function formatNonDownloadableResource(resource: DesignResourceCatalogEntry): string { + return ( + '# Apple Design Resource\n\n' + + `${resource.title} is catalog metadata or an external design-tool link, so it was not downloaded.\n\n` + + `- Resource ID: ${resource.resourceId}\n` + + `- Format: ${resource.format}\n` + + `- URL: ${resource.downloadUrl}` + ); +} + +async function collectImageCandidates(args: GetAppleDesignExamplesArgs): Promise { + const candidates: DesignImageCandidate[] = []; + + if (args.resourceId) { + if (!resourceCatalogById.has(args.resourceId)) { + await getDesignResourcesCatalog(); + } + const resource = resourceCatalogById.get(args.resourceId); + if (!resource) { + throw new Error(`Unknown Apple Design resourceId: ${args.resourceId}`); + } + + if (resource.previewImageUrl) { + candidates.push({ + url: resource.previewImageUrl, + alt: resource.title, + }); + } + } + + if (args.query) { + const resources = filterDesignResources(await getDesignResourcesCatalog(), { + searchQuery: args.query, + limit: args.limit, + }); + const queryPreviewLimit = args.limit ?? 3; + let queryPreviewCount = 0; + for (const resource of resources) { + if (resource.previewImageUrl) { + candidates.push({ + url: resource.previewImageUrl, + alt: resource.title, + }); + queryPreviewCount++; + if (queryPreviewCount >= queryPreviewLimit) { + break; + } + } + } + } + + if (args.url) { + const url = args.url; + validateAppleDesignExampleUrl(url); + if (isDirectAppleImageCandidateUrl(url)) { + candidates.push({ url, strict: true }); + } else { + const jsonUrl = convertToDesignJsonApiUrl(url); + if (jsonUrl) { + const jsonData = await fetchAppleDesignJson(jsonUrl); + candidates.push(...extractImageCandidatesFromDesignDocument(jsonData, url)); + } else { + const { html, finalUrl } = await fetchAppleDesignHtml(url); + candidates.push(...extractImageCandidatesFromHtml(html, finalUrl)); + } + } + } + + return candidates; +} + +async function fetchImageContent(url: string): Promise { + const parsedUrl = safeUrl(url); + if ( + !parsedUrl + || parsedUrl.protocol !== 'https:' + || !APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname) + ) { + return null; + } + + const { + response, + finalUrl, + } = await fetchAppleDesignAssetResponse(url, 'Apple Design image preview', { + headers: { + Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + }, + }); + + const mimeType = detectMimeType(response.headers.get('content-type'), finalUrl); + if (!isImageMimeType(mimeType)) { + return null; + } + + const data = await readLimitedResponseBytes( + response, + DEFAULT_PREVIEW_IMAGE_MAX_BYTES, + 'Apple Design image preview', + ); + return { + type: 'image', + data: data.toString('base64'), + mimeType, + }; +} + +function extractImageCandidatesFromDesignDocument( + jsonData: unknown, + sourceUrl: string, +): DesignImageCandidate[] { + const documentRecord = asRecord(jsonData); + if (!documentRecord) { + return []; + } + + const references = getRecordMap(documentRecord.references); + const candidates: DesignImageCandidate[] = []; + for (const reference of references.values()) { + if (getString(reference, 'type') !== 'image') { + continue; + } + const imageUrl = getImageUrlFromReference(reference, sourceUrl); + if (imageUrl) { + candidates.push({ + url: imageUrl, + alt: getString(reference, 'alt') ?? getString(reference, 'title'), + }); + } + } + + collectImageCandidatesFromContent(getArray(documentRecord.primaryContentSections), references, sourceUrl, candidates); + collectImageCandidatesFromContent(getArray(documentRecord.sections), references, sourceUrl, candidates); + return candidates; +} + +function collectImageCandidatesFromContent( + nodes: unknown[], + references: Map>, + sourceUrl: string, + candidates: DesignImageCandidate[], +): void { + for (const node of nodes) { + const record = asRecord(node); + if (!record) { + continue; + } + + const type = getString(record, 'type'); + if (type === 'image' || type === 'icon') { + const imageUrl = resolveImageBlockUrl(record, references, sourceUrl); + if (imageUrl) { + candidates.push({ + url: imageUrl, + alt: getString(record, 'alt') ?? getString(record, 'title'), + }); + } + } + + for (const nestedKey of ['content', 'items', 'rows', 'cells', 'inlineContent', 'tabs']) { + collectImageCandidatesFromContent(getArray(record[nestedKey]), references, sourceUrl, candidates); + } + } +} + +function extractImageCandidatesFromHtml(html: string, sourceUrl: string): DesignImageCandidate[] { + const $ = cheerio.load(html); + const candidates: DesignImageCandidate[] = []; + $('main img, body img').each((_index, element) => { + const image = $(element); + const imageUrl = normalizeUrl(image.attr('src') ?? image.attr('data-src'), sourceUrl); + if (!imageUrl) { + return; + } + candidates.push({ + url: imageUrl, + alt: image.attr('alt') ?? undefined, + }); + }); + return candidates; +} + +function dedupeImageCandidates(candidates: DesignImageCandidate[]): DesignImageCandidate[] { + const seenUrls = new Set(); + const dedupedCandidates: DesignImageCandidate[] = []; + for (const candidate of candidates) { + if (seenUrls.has(candidate.url)) { + continue; + } + seenUrls.add(candidate.url); + dedupedCandidates.push(candidate); + } + return dedupedCandidates; +} + +function parseDesignResourceItem( + $: cheerio.CheerioAPI, + item: cheerio.Cheerio, + category: string, + platform: string | undefined, + sourceUrl: string, + seenResourceIds: Map, +): DesignResourceCatalogEntry[] { + const title = normalizeWhitespace(item.find('h5, h4, h3').first().text()) || category; + const previewImageUrl = normalizeUrl( + item.find('img.download-image, img').first().attr('src'), + sourceUrl, + ); + const noteParts = item.find('.download-text-description').map((_index, element) => { + return normalizeWhitespace($(element).text()); + }).get().filter(Boolean); + const note = noteParts.join(' ') || undefined; + const relatedGuidelineLinks = item.find('a[href*="/design/human-interface-guidelines"]').map((_index, element) => { + return normalizeUrl($(element).attr('href'), sourceUrl); + }).get().filter((link): link is string => Boolean(link)); + const links = item.find('a[href]'); + const entries: DesignResourceCatalogEntry[] = []; + const seenLinks = new Set(); + + links.each((_index, element) => { + const link = $(element); + const href = link.attr('href'); + const label = normalizeWhitespace(link.text()) || 'Download'; + if (!href || !isResourceCatalogLink(link, href, label)) { + return; + } + + const downloadUrl = normalizeUrl(href, sourceUrl); + if (!downloadUrl) { + return; + } + + const linkKey = `${label}:${downloadUrl}`; + if (seenLinks.has(linkKey)) { + return; + } + seenLinks.add(linkKey); + + const format = inferFormat(downloadUrl, label); + const resourceId = createResourceId(category, platform, title, label, downloadUrl, seenResourceIds); + entries.push({ + resourceId, + category, + platform: platform || undefined, + title, + downloadLabel: label, + downloadUrl, + format, + note, + previewImageUrl, + relatedGuidelineLinks: [...new Set(relatedGuidelineLinks)], + }); + }); + + return entries; +} + +function formatDesignContentSection( + section: Record, + references: Map>, + sourceUrl: string, + defaultHeadingLevel: number, +): string { + let content = ''; + const title = getString(section, 'title'); + if (title) { + content += `${'#'.repeat(defaultHeadingLevel)} ${title}\n\n`; + } + + const blocks = getArray(section.content); + for (const block of blocks) { + content += formatDesignBlock(block, references, sourceUrl, defaultHeadingLevel); + } + + return content; +} + +function formatSecondarySection( + section: Record, + references: Map>, + sourceUrl: string, +): string { + const title = getString(section, 'title') ?? ''; + const kind = getString(section, 'kind') ?? ''; + const isChangeLog = `${title} ${kind}`.toLowerCase().includes('change'); + if (isChangeLog) { + return formatChangeLogSection(section, references, sourceUrl); + } + + return formatDesignContentSection(section, references, sourceUrl, 2); +} + +function formatChangeLogSection( + section: Record, + references: Map>, + sourceUrl: string, +): string { + let content = '## Change Log\n\n'; + const items = getRecordArray(section.items); + for (const item of items) { + const date = getString(item, 'date') ?? getString(item, 'title'); + if (date) { + content += `### ${date}\n\n`; + } + for (const block of getArray(item.content)) { + content += formatDesignBlock(block, references, sourceUrl, 4); + } + } + return content; +} + +function formatTopicSections( + topicSections: Record[], + references: Map>, + sourceUrl: string, +): string { + let content = '## Topics\n\n'; + for (const section of topicSections) { + const title = getString(section, 'title'); + if (title) { + content += `### ${title}\n\n`; + } + + for (const identifier of getStringArray(section.identifiers)) { + const reference = references.get(identifier); + if (!reference) { + continue; + } + const referenceTitle = getString(reference, 'title') ?? getIdentifierTitle(identifier); + const referenceUrl = normalizeReferenceUrl(reference, sourceUrl); + const abstractText = renderInlineCollection(getArray(reference.abstract), references, sourceUrl); + if (referenceUrl) { + content += `- [${referenceTitle}](${referenceUrl})`; + } else { + content += `- ${referenceTitle}`; + } + if (abstractText) { + content += `: ${abstractText}`; + } + content += '\n'; + } + content += '\n'; + } + return content; +} + +function formatDesignBlock( + block: unknown, + references: Map>, + sourceUrl: string, + headingLevel: number, +): string { + const record = asRecord(block); + if (!record) { + return ''; + } + + const type = getString(record, 'type') ?? getString(record, 'kind'); + if (type === 'heading') { + const rawLevel = getNumber(record, 'level') ?? headingLevel; + const level = Math.min(Math.max(Math.trunc(rawLevel), 1), 6); + const text = getString(record, 'text') ?? renderInlineCollection(getArray(record.inlineContent), references, sourceUrl); + return text ? `${'#'.repeat(level)} ${text}\n\n` : ''; + } + + if (type === 'paragraph') { + const text = renderInlineCollection(getArray(record.inlineContent), references, sourceUrl); + return text ? `${text}\n\n` : ''; + } + + if (type === 'image' || type === 'icon') { + const imageUrl = resolveImageBlockUrl(record, references, sourceUrl); + if (!imageUrl) { + return ''; + } + const altText = resolveImageAltText(record, references) ?? 'Apple Design image'; + return `![${altText}](${imageUrl})\n\n`; + } + + if (type === 'unorderedList' || type === 'orderedList') { + return formatListBlock(record, references, sourceUrl, type === 'orderedList'); + } + + if (type === 'table') { + return formatTableBlock(record, references, sourceUrl); + } + + if (type === 'aside' || type === 'alert') { + const blocks = getArray(record.content); + const asideText = blocks.map(item => formatDesignBlock(item, references, sourceUrl, headingLevel + 1).trim()) + .filter(Boolean) + .join('\n\n'); + return asideText ? `> ${asideText.replaceAll('\n', '\n> ')}\n\n` : ''; + } + + if (type === 'links' || type === 'linkGrid') { + return formatLinksBlock(record, references, sourceUrl); + } + + if (type === 'tabNavigator' || type === 'tabs') { + return formatTabsBlock(record, references, sourceUrl, headingLevel); + } + + if (type === 'reference') { + const identifier = getString(record, 'identifier'); + if (!identifier) { + return ''; + } + const reference = references.get(identifier); + const referenceTitle = reference ? getString(reference, 'title') : undefined; + const referenceUrl = reference ? normalizeReferenceUrl(reference, sourceUrl) : undefined; + if (referenceTitle && referenceUrl) { + return `- [${referenceTitle}](${referenceUrl})\n\n`; + } + } + + const nestedContent = getArray(record.content); + if (nestedContent.length > 0) { + return nestedContent.map(item => formatDesignBlock(item, references, sourceUrl, headingLevel)).join(''); + } + + return renderInlineCollection(getArray(record.inlineContent), references, sourceUrl); +} + +function formatListBlock( + block: Record, + references: Map>, + sourceUrl: string, + ordered: boolean, +): string { + let content = ''; + const items = getArray(block.items); + items.forEach((item, index) => { + const itemRecord = asRecord(item); + if (!itemRecord) { + return; + } + + const itemContent = getArray(itemRecord.content) + .map(contentBlock => formatDesignBlock(contentBlock, references, sourceUrl, 4).trim()) + .filter(Boolean) + .join(' '); + if (itemContent) { + content += `${ordered ? `${index + 1}.` : '-'} ${itemContent}\n`; + } + }); + + return content ? `${content}\n` : ''; +} + +function formatTableBlock( + block: Record, + references: Map>, + sourceUrl: string, +): string { + const headerCells = getArray(block.header).map(cell => renderTableCell(cell, references, sourceUrl)); + const rows = getRecordArray(block.rows); + if (headerCells.length === 0 || rows.length === 0) { + return ''; + } + + let content = `| ${headerCells.join(' | ')} |\n`; + content += `| ${headerCells.map(() => '---').join(' | ')} |\n`; + for (const row of rows) { + const cells = getArray(row.cells).map(cell => renderTableCell(cell, references, sourceUrl)); + content += `| ${cells.join(' | ')} |\n`; + } + return `${content}\n`; +} + +function formatLinksBlock( + block: Record, + references: Map>, + sourceUrl: string, +): string { + let content = ''; + for (const identifier of getStringArray(block.identifiers)) { + const reference = references.get(identifier); + if (!reference) { + continue; + } + const title = getString(reference, 'title') ?? getIdentifierTitle(identifier); + const url = normalizeReferenceUrl(reference, sourceUrl); + if (url) { + content += `- [${title}](${url})\n`; + } + } + + const links = getRecordArray(block.links); + for (const link of links) { + const title = getString(link, 'title') ?? renderInlineCollection(getArray(link.inlineContent), references, sourceUrl); + const url = normalizeUrl(getString(link, 'url'), sourceUrl); + if (title && url) { + content += `- [${title}](${url})\n`; + } + } + + return content ? `${content}\n` : ''; +} + +function formatTabsBlock( + block: Record, + references: Map>, + sourceUrl: string, + headingLevel: number, +): string { + let content = ''; + const tabs = getRecordArray(block.tabs); + for (const tab of tabs) { + const title = getString(tab, 'title'); + if (title) { + content += `${'#'.repeat(Math.min(headingLevel + 1, 6))} ${title}\n\n`; + } + for (const tabBlock of getArray(tab.content)) { + content += formatDesignBlock(tabBlock, references, sourceUrl, headingLevel + 2); + } + } + return content; +} + +function renderTableCell( + cell: unknown, + references: Map>, + sourceUrl: string, +): string { + const cellRecord = asRecord(cell); + if (!cellRecord) { + return ''; + } + + const inlineText = renderInlineCollection(getArray(cellRecord.inlineContent), references, sourceUrl); + if (inlineText) { + return inlineText.replaceAll('|', '\\|'); + } + + const blockText = getArray(cellRecord.content) + .map(block => formatDesignBlock(block, references, sourceUrl, 4).trim()) + .join(' '); + return blockText.replaceAll('|', '\\|'); +} + +function renderInlineCollection( + inlineContent: unknown[], + references: Map>, + sourceUrl: string, +): string { + return inlineContent.map(item => renderInlineContent(item, references, sourceUrl)).join(''); +} + +function renderInlineContent( + inline: unknown, + references: Map>, + sourceUrl: string, +): string { + const record = asRecord(inline); + if (!record) { + return ''; + } + + const type = getString(record, 'type'); + if (type === 'text') { + return getString(record, 'text') ?? ''; + } + + if (type === 'strong') { + return `**${renderInlineCollection(getArray(record.inlineContent), references, sourceUrl)}**`; + } + + if (type === 'emphasis') { + return `*${renderInlineCollection(getArray(record.inlineContent), references, sourceUrl)}*`; + } + + if (type === 'codeVoice' || type === 'code') { + return `\`${getString(record, 'code') ?? getString(record, 'text') ?? ''}\``; + } + + if (type === 'reference') { + const identifier = getString(record, 'identifier'); + if (!identifier) { + return ''; + } + const reference = references.get(identifier); + const title = getString(record, 'title') ?? getString(reference, 'title') ?? getIdentifierTitle(identifier); + const url = reference ? normalizeReferenceUrl(reference, sourceUrl) : undefined; + if (url) { + return `[${title}](${url})`; + } + return title; + } + + if (type === 'link') { + const title = getString(record, 'title') + ?? getString(record, 'text') + ?? renderInlineCollection(getArray(record.inlineContent), references, sourceUrl); + const url = normalizeUrl(getString(record, 'url'), sourceUrl); + if (title && url) { + return `[${title}](${url})`; + } + return title; + } + + const nestedInlineContent = getArray(record.inlineContent); + if (nestedInlineContent.length > 0) { + return renderInlineCollection(nestedInlineContent, references, sourceUrl); + } + + return getString(record, 'text') ?? ''; +} + +function getSupportedPlatforms(documentRecord: Record): string[] { + const customMetadata = getDesignCustomMetadata(documentRecord); + const supportedPlatforms = customMetadata?.['supported-platforms'] + ?? customMetadata?.supportedPlatforms + ?? documentRecord['supported-platforms'] + ?? documentRecord.supportedPlatforms + ?? documentRecord.platforms; + return getStringArray(supportedPlatforms); +} + +function designRecordMatchesPlatform( + record: Record, + platform: string, + fallbackText: string, +): boolean { + if (platform === 'all') { + return true; + } + + const supportedPlatforms = getSupportedPlatforms(record); + if (supportedPlatforms.length === 0) { + return containsText(fallbackText, platform); + } + + return supportedPlatforms.some(supportedPlatform => containsText(supportedPlatform, platform)); +} + +function getCustomMetadataString(documentRecord: Record, key: string): string | undefined { + const customMetadata = getDesignCustomMetadata(documentRecord); + return getString(customMetadata, key); +} + +function getDesignCustomMetadata( + documentRecord: Record, +): Record | undefined { + const rootCustomMetadata = asRecord(documentRecord.customMetadata); + if (rootCustomMetadata) { + return rootCustomMetadata; + } + + const metadata = asRecord(documentRecord.metadata); + return asRecord(metadata?.customMetadata); +} + +function resolveImageBlockUrl( + block: Record, + references: Map>, + sourceUrl: string, +): string | undefined { + const directUrl = normalizeUrl(getString(block, 'url') ?? getString(block, 'source'), sourceUrl); + if (directUrl) { + return directUrl; + } + + const identifier = getString(block, 'identifier'); + if (!identifier) { + return undefined; + } + + const reference = references.get(identifier); + if (!reference) { + return undefined; + } + + return getImageUrlFromReference(reference, sourceUrl); +} + +function resolveImageAltText( + block: Record, + references: Map>, +): string | undefined { + const directAlt = getString(block, 'alt') ?? getString(block, 'title'); + if (directAlt) { + return directAlt; + } + + const identifier = getString(block, 'identifier'); + if (!identifier) { + return undefined; + } + + const reference = references.get(identifier); + return getString(reference, 'alt') ?? getString(reference, 'title'); +} + +function getImageUrlFromReference( + reference: Record, + sourceUrl: string, +): string | undefined { + const directUrl = normalizeUrl(getString(reference, 'url'), sourceUrl); + if (directUrl && isDirectImageUrl(directUrl)) { + return directUrl; + } + + const variants = getRecordArray(reference.variants); + for (const variant of variants) { + const variantUrl = normalizeUrl(getString(variant, 'url'), sourceUrl); + if (variantUrl) { + return variantUrl; + } + } + + const images = getRecordArray(reference.images); + for (const image of images) { + const imageUrl = normalizeUrl(getString(image, 'url'), sourceUrl); + if (imageUrl) { + return imageUrl; + } + } + + return undefined; +} + +function normalizeReferenceUrl( + reference: Record | undefined, + sourceUrl: string, +): string | undefined { + if (!reference) { + return undefined; + } + + const referenceUrl = getString(reference, 'url'); + return normalizeUrl(referenceUrl, sourceUrl); +} + +function updateResourceCatalogIndex(resources: DesignResourceCatalogEntry[]): void { + for (const resource of resources) { + resourceCatalogById.set(resource.resourceId, resource); + } +} + +function isResourceCatalogLink( + link: cheerio.Cheerio, + href: string, + label: string, +): boolean { + if (link.hasClass('download-text-link')) { + return true; + } + + if (href.startsWith('sketch://') || href.includes('figma.com')) { + return true; + } + + const parsedUrl = safeUrl(href); + const pathname = parsedUrl?.pathname ?? href; + const extension = path.extname(pathname).toLowerCase(); + if (DIRECT_RESOURCE_EXTENSIONS.has(extension)) { + return true; + } + + const normalizedLabel = label.toLowerCase(); + return normalizedLabel.includes('download') + || normalizedLabel.includes('figma') + || normalizedLabel.includes('sketch'); +} + +function createResourceId( + category: string, + platform: string | undefined, + title: string, + label: string, + downloadUrl: string, + seenResourceIds: Map, +): string { + const baseId = [ + 'design-resource', + slugify(category), + slugify(platform || 'all'), + slugify(title), + slugify(label), + hashString(downloadUrl).slice(0, 12), + ].join(':'); + const seenCount = seenResourceIds.get(baseId) ?? 0; + seenResourceIds.set(baseId, seenCount + 1); + if (seenCount === 0) { + return baseId; + } + return `${baseId}:${seenCount + 1}`; +} + +function findNearestHeadingText( + $: cheerio.CheerioAPI, + item: cheerio.Cheerio, +): string | undefined { + const previousHeading = item.prevAll('h4, h3').first(); + if (previousHeading.length > 0) { + return normalizeWhitespace(previousHeading.text()); + } + + const parentPreviousHeading = item.parent().prevAll('h4, h3').first(); + if (parentPreviousHeading.length > 0) { + return normalizeWhitespace(parentPreviousHeading.text()); + } + + const sectionHeading = item.closest('section').find('h4, h3').first(); + const headingText = normalizeWhitespace($(sectionHeading).text()); + return headingText || undefined; +} + +function normalizeUrl(url: string | undefined, sourceUrl: string): string | undefined { + if (!url) { + return undefined; + } + + try { + return new URL(url, sourceUrl).toString(); + } catch { + return undefined; + } +} + +function safeUrl(url: string): URL | undefined { + try { + return new URL(url); + } catch { + return undefined; + } +} + +function isDesignResourcesUrl(url: string): boolean { + const parsedUrl = safeUrl(url); + return parsedUrl?.hostname === 'developer.apple.com' && parsedUrl.pathname.startsWith('/design/resources'); +} + +function validateAppleDesignContentUrl(url: string): void { + if (!isAppleDesignContentUrl(url)) { + throw new Error('Apple Design content URLs must use https://developer.apple.com/design/.'); + } +} + +function validateAppleDesignExampleUrl(url: string): void { + if (isDirectImageUrl(url) || isDirectAppleImageCandidateUrl(url)) { + validateAppleDesignImageUrl(url); + return; + } + + validateAppleDesignContentUrl(url); +} + +function validateAppleDesignImageUrl(url: string): void { + const parsedUrl = safeUrl(url); + if (!parsedUrl || parsedUrl.protocol !== 'https:' || !APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)) { + throw new Error('Apple Design image URLs must use HTTPS and an allowed Apple host.'); + } +} + +function isAppleDesignContentUrl(url: string): boolean { + const parsedUrl = safeUrl(url); + return Boolean( + parsedUrl + && parsedUrl.protocol === 'https:' + && parsedUrl.hostname === 'developer.apple.com' + && (parsedUrl.pathname === '/design' || parsedUrl.pathname.startsWith('/design/')), + ); +} + +function isAppleDesignJsonUrl(url: string): boolean { + const parsedUrl = safeUrl(url); + return Boolean( + parsedUrl + && parsedUrl.protocol === 'https:' + && parsedUrl.hostname === 'developer.apple.com' + && parsedUrl.pathname.startsWith('/tutorials/data/design/'), + ); +} + +function isDirectAppleDownloadUrl(url: string): boolean { + const parsedUrl = safeUrl(url); + return Boolean(parsedUrl && parsedUrl.protocol === 'https:' && APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)); +} + +function validateDownloadUrl(url: string): void { + const parsedUrl = safeUrl(url); + if (!parsedUrl || parsedUrl.protocol !== 'https:') { + throw new Error('Apple Design downloads must use HTTPS URLs.'); + } + + if (!APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)) { + throw new Error(`Download domain is not allowed: ${parsedUrl.hostname}`); + } +} + +async function fetchAppleDesignHtml(url: string): Promise<{ html: string; finalUrl: string }> { + const { response, finalUrl } = await fetchAppleDesignManualRedirectResponse( + url, + 'Apple Design content page', + { + headers: { + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + validateUrl: validateAppleDesignContentRedirectUrl, + }, + ); + const data = await readLimitedResponseBytes( + response, + DEFAULT_DESIGN_CONTENT_MAX_BYTES, + 'Apple Design content page', + ); + + return { + html: data.toString('utf8'), + finalUrl, + }; +} + +async function fetchAppleDesignJson(url: string): Promise { + const { response } = await fetchAppleDesignManualRedirectResponse( + url, + 'Apple Design JSON', + { + headers: { + Accept: 'application/json,*/*;q=0.8', + }, + validateUrl: validateAppleDesignJsonRedirectUrl, + }, + ); + const data = await readLimitedResponseBytes( + response, + DEFAULT_DESIGN_CONTENT_MAX_BYTES, + 'Apple Design JSON', + ); + + return JSON.parse(data.toString('utf8')) as unknown; +} + +async function fetchAppleDesignAssetResponse( + url: string, + description: string, + options: { headers: Record }, +): Promise<{ response: Response; finalUrl: string }> { + return fetchAppleDesignManualRedirectResponse(url, description, { + headers: options.headers, + validateUrl: validateAppleDesignRedirectUrl, + }); +} + +async function fetchAppleDesignManualRedirectResponse( + url: string, + description: string, + options: { + headers: Record; + validateUrl: AppleDesignUrlValidator; + }, +): Promise<{ response: Response; finalUrl: string }> { + let currentUrl = url; + options.validateUrl(currentUrl, description); + + for (let redirectCount = 0; redirectCount <= MAX_APPLE_DESIGN_REDIRECTS; redirectCount++) { + const response = await httpClient.get(currentUrl, { + timeout: REQUEST_CONFIG.TIMEOUT, + redirect: 'manual', + allowManualRedirect: true, + headers: options.headers, + }); + validateAppleDesignFinalResponseUrl(response, description, options.validateUrl); + + if (!isRedirectResponse(response)) { + return { + response, + finalUrl: response.url || currentUrl, + }; + } + + if (redirectCount === MAX_APPLE_DESIGN_REDIRECTS) { + await cancelResponseBody(response); + throw new Error(`${description} exceeded the ${MAX_APPLE_DESIGN_REDIRECTS} redirect limit.`); + } + + let redirectUrl: string; + try { + redirectUrl = getAppleDesignRedirectUrl(response, currentUrl, description); + options.validateUrl(redirectUrl, description); + } catch (error) { + await cancelResponseBody(response); + throw error; + } + + await cancelResponseBody(response); + currentUrl = redirectUrl; + } + + throw new Error(`${description} exceeded the ${MAX_APPLE_DESIGN_REDIRECTS} redirect limit.`); +} + +function getAppleDesignRedirectUrl( + response: Response, + currentUrl: string, + description: string, +): string { + const location = response.headers.get('location'); + if (!location) { + throw new Error(`${description} returned a redirect without a Location header.`); + } + + const redirectUrl = normalizeUrl(location, currentUrl); + if (!redirectUrl) { + throw new Error(`${description} returned an invalid redirect URL.`); + } + + return redirectUrl; +} + +function isRedirectResponse(response: Response): boolean { + return response.status >= 300 && response.status < 400; +} + +function validateAppleDesignRedirectUrl(url: string, description: string): void { + const parsedUrl = safeUrl(url); + if (!parsedUrl || parsedUrl.protocol !== 'https:' || !APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)) { + throw new Error(`${description} redirected to a URL outside the Apple Design allowlist.`); + } +} + +function validateAppleDesignContentRedirectUrl(url: string, description: string): void { + if (!isAppleDesignContentUrl(url)) { + throw new Error(`${description} redirected to a URL outside the Apple Design content allowlist.`); + } +} + +function validateAppleDesignJsonRedirectUrl(url: string, description: string): void { + if (!isAppleDesignJsonUrl(url)) { + throw new Error(`${description} redirected to a URL outside the Apple Design JSON allowlist.`); + } +} + +function validateAppleDesignFinalResponseUrl( + response: Response, + description: string, + validateUrl: AppleDesignUrlValidator, +): void { + if (!response.url) { + return; + } + + validateUrl(response.url, description); +} + +function isDirectAppleImageCandidateUrl(url: string): boolean { + const parsedUrl = safeUrl(url); + if (!parsedUrl || parsedUrl.protocol !== 'https:' || !APPLE_DESIGN_DOWNLOAD_HOSTS.has(parsedUrl.hostname)) { + return false; + } + + if (parsedUrl.hostname !== 'developer.apple.com') { + return true; + } + + return isDirectImageUrl(url); +} + +function isDirectImageUrl(url: string): boolean { + const mimeType = detectMimeType(undefined, url); + return isImageMimeType(mimeType); +} + +function inferFormat(url: string, label: string): string { + const normalizedLabel = label.toLowerCase(); + if (normalizedLabel.includes('figma') || url.includes('figma.com')) { + return 'figma'; + } + if (normalizedLabel.includes('sketch') || url.startsWith('sketch://')) { + return 'sketch'; + } + + const parsedUrl = safeUrl(url); + const extension = path.extname(parsedUrl?.pathname ?? url).replace('.', '').toLowerCase(); + if (extension) { + return extension; + } + + return 'external'; +} + +function detectMimeType(contentType: string | null | undefined, url: string): string { + if (contentType) { + return contentType.split(';')[0].trim().toLowerCase(); + } + + const parsedUrl = safeUrl(url); + const extension = path.extname(parsedUrl?.pathname ?? url).toLowerCase(); + switch (extension) { + case '.dmg': + return 'application/x-apple-diskimage'; + case '.gif': + return 'image/gif'; + case '.jpeg': + case '.jpg': + return 'image/jpeg'; + case '.pdf': + return 'application/pdf'; + case '.png': + return 'image/png'; + case '.svg': + return 'image/svg+xml'; + case '.webp': + return 'image/webp'; + case '.zip': + return 'application/zip'; + default: + return 'application/octet-stream'; + } +} + +function isImageMimeType(mimeType: string): boolean { + return mimeType.startsWith(IMAGE_MIME_PREFIX); +} + +function getContentLength(response: Response): number | undefined { + const contentLengthHeader = response.headers.get('content-length'); + if (!contentLengthHeader) { + return undefined; + } + + const contentLength = Number.parseInt(contentLengthHeader, 10); + if (Number.isNaN(contentLength)) { + return undefined; + } + return contentLength; +} + +async function readLimitedResponseBytes( + response: Response, + byteLimit: number, + description: string, +): Promise { + const contentLength = getContentLength(response); + if (contentLength !== undefined && contentLength > byteLimit) { + await cancelResponseBody(response); + throw new Error(`${description} exceeds the ${byteLimit} byte download limit.`); + } + + if (!response.body) { + const data = Buffer.from(await response.arrayBuffer()); + if (data.length > byteLimit) { + throw new Error(`${description} exceeds the ${byteLimit} byte download limit.`); + } + return data; + } + + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let totalBytes = 0; + + try { + let result = await reader.read(); + while (!result.done) { + totalBytes += result.value.byteLength; + if (totalBytes > byteLimit) { + await reader.cancel().catch(() => undefined); + throw new Error(`${description} exceeds the ${byteLimit} byte download limit.`); + } + + chunks.push(Buffer.from(result.value)); + result = await reader.read(); + } + } finally { + reader.releaseLock(); + } + + return Buffer.concat(chunks, totalBytes); +} + +async function assertDesignCacheCapacity( + cacheDirectory: string, + incomingBytes: number, +): Promise { + const cacheMaxBytes = getDesignCacheMaxBytes(); + const currentBytes = await getDirectoryFileBytes(cacheDirectory); + const projectedBytes = currentBytes + incomingBytes; + + if (projectedBytes > cacheMaxBytes) { + throw new Error( + `Apple Design download cache exceeds the ${cacheMaxBytes} byte cache limit.`, + ); + } +} + +async function writeExclusiveDesignCacheFile( + cacheDirectory: string, + hash: string, + filename: string, + data: Buffer, +): Promise<{ cacheFilename: string; filePath: string }> { + for (let attemptCount = 0; attemptCount < 3; attemptCount++) { + const cacheFilename = createCacheFilename(hash, filename); + const filePath = path.join(cacheDirectory, cacheFilename); + + try { + const fileHandle = await open(filePath, 'wx', 0o600); + let writeError: unknown; + try { + await fileHandle.writeFile(data); + } catch (error) { + writeError = error; + } finally { + await fileHandle.close(); + } + if (writeError) { + await rm(filePath, { force: true }).catch(() => undefined); + throw writeError; + } + + return { + cacheFilename, + filePath, + }; + } catch (error) { + if (hasErrorCode(error, 'EEXIST')) { + continue; + } + throw error; + } + } + + throw new Error('Apple Design cache could not create a unique resource file.'); +} + +async function withDesignCacheWriteLock(operation: () => Promise): Promise { + const previousWrite = designCacheWriteQueue; + let releaseLock: (() => void) | undefined; + designCacheWriteQueue = new Promise((resolve) => { + releaseLock = resolve; + }); + + await previousWrite.catch(() => undefined); + try { + return await operation(); + } finally { + if (releaseLock) { + releaseLock(); + } + } +} + +function createCacheFilename(hash: string, filename: string): string { + return `${hash}-${randomUUID()}-${filename}`; +} + +function getDesignCacheMaxBytes(): number { + const configuredLimit = process.env.APPLE_DOCS_MCP_CACHE_MAX_BYTES; + if (!configuredLimit) { + return DEFAULT_DOWNLOAD_CACHE_MAX_BYTES; + } + + const parsedLimit = Number.parseInt(configuredLimit, 10); + if (Number.isNaN(parsedLimit) || parsedLimit <= 0) { + return DEFAULT_DOWNLOAD_CACHE_MAX_BYTES; + } + + return parsedLimit; +} + +async function getDirectoryFileBytes(directory: string): Promise { + let entries: Dirent[]; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return 0; + } + throw error; + } + + let totalBytes = 0; + for (const entry of entries) { + if (entry.isFile()) { + totalBytes += await getFileSize(path.join(directory, entry.name)); + } + } + + return totalBytes; +} + +async function getFileSize(filePath: string): Promise { + try { + const fileStats = await stat(filePath); + if (!fileStats.isFile()) { + return 0; + } + return fileStats.size; + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return 0; + } + throw error; + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + + const errorWithCode = error as { code?: unknown }; + return errorWithCode.code === code; +} + +function getDesignCacheDirectory(): string { + const configuredCacheDirectory = process.env.APPLE_DOCS_MCP_CACHE_DIR; + if (configuredCacheDirectory) { + return configuredCacheDirectory; + } + + if (!defaultDesignCacheDirectory) { + defaultDesignCacheDirectory = mkdtempSync(path.join(tmpdir(), 'apple-docs-mcp-design-')); + } + + return defaultDesignCacheDirectory; +} + +function createTextContent(text: string): ContentBlock { + return { + type: 'text', + text, + }; +} + +function createResourceLink(cachedResource: CachedDesignResource): ResourceLink { + return { + type: 'resource_link', + uri: cachedResource.uri, + name: cachedResource.name, + title: cachedResource.title, + description: cachedResource.description, + mimeType: cachedResource.mimeType, + }; +} + +function containsText(source: string | undefined, query: string): boolean { + if (!source) { + return false; + } + return source.toLowerCase().includes(query.toLowerCase()); +} + +function resourceMatchesPlatform(resource: DesignResourceCatalogEntry, platform: string): boolean { + if (platform === 'all') { + return true; + } + return containsText(resource.platform, platform) || containsText(resource.title, platform); +} + +function getFilenameFromUrl(url: string): string { + const parsedUrl = safeUrl(url); + const basename = path.basename(parsedUrl?.pathname ?? url); + return basename || 'apple-design-resource'; +} + +function sanitizeFilename(filename: string): string { + const sanitizedFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '-'); + return sanitizedFilename || 'apple-design-resource'; +} + +function slugify(value: string): string { + const slug = value.toLowerCase() + .replaceAll('&', 'and') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return slug || 'resource'; +} + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +function hashString(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function hashBuffer(value: Buffer): string { + return createHash('sha256').update(value).digest('hex'); +} + +function getIdentifierTitle(identifier: string): string { + return identifier.split('/').pop() ?? identifier; +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record; + } + return undefined; +} + +function getRecordMap(value: unknown): Map> { + const valueRecord = asRecord(value); + const map = new Map>(); + if (!valueRecord) { + return map; + } + + for (const [key, entryValue] of Object.entries(valueRecord)) { + const entryRecord = asRecord(entryValue); + if (entryRecord) { + map.set(key, entryRecord); + } + } + + return map; +} + +function getRecordArray(value: unknown): Record[] { + return getArray(value).flatMap(item => { + const record = asRecord(item); + if (!record) { + return []; + } + return [record]; + }); +} + +function getArray(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value; + } + return []; +} + +function getString(value: unknown, key: string): string | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + + const propertyValue = record[key]; + if (typeof propertyValue === 'string') { + return propertyValue; + } + return undefined; +} + +function getNumber(value: unknown, key: string): number | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + + const propertyValue = record[key]; + if (typeof propertyValue === 'number') { + return propertyValue; + } + return undefined; +} + +function getStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string'); + } + + if (typeof value === 'string') { + return [value]; + } + + return []; +} + +export async function removeCachedDesignResourcesForTesting(): Promise { + const cacheDirectory = getDesignCacheDirectory(); + await rm(cacheDirectory, { + force: true, + recursive: true, + }); + if (!process.env.APPLE_DOCS_MCP_CACHE_DIR) { + defaultDesignCacheDirectory = undefined; + } +} diff --git a/src/tools/handlers.ts b/src/tools/handlers.ts index 0b5c443..56c3f03 100644 --- a/src/tools/handlers.ts +++ b/src/tools/handlers.ts @@ -2,6 +2,7 @@ * Tool handlers for Apple Developer Documentation MCP Server */ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { searchAppleDocsSchema, getAppleDocContentSchema, @@ -14,6 +15,11 @@ import { getDocumentationUpdatesSchema, getTechnologyOverviewsSchema, getSampleCodeSchema, + searchAppleDesignDocsSchema, + getAppleDesignContentSchema, + listAppleDesignResourcesSchema, + downloadAppleDesignResourceSchema, + getAppleDesignExamplesSchema, } from '../schemas/index.js'; import { listWWDCVideosSchema, @@ -39,7 +45,7 @@ import { export type ToolHandler = ( args: unknown, server: any -) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; +) => Promise; /** * Map of tool names to their handlers @@ -90,7 +96,17 @@ export const toolHandlers: Record = { }, get_cache_stats: async () => { - const { apiCache, searchCache, indexCache, technologiesCache, updatesCache, sampleCodeCache, technologyOverviewsCache } = await import('../utils/cache.js'); + const { + apiCache, + searchCache, + indexCache, + technologiesCache, + updatesCache, + sampleCodeCache, + technologyOverviewsCache, + designContentCache, + designResourcesCache, + } = await import('../utils/cache.js'); const stats = { apiCache: apiCache.getStats(), @@ -100,6 +116,8 @@ export const toolHandlers: Record = { updatesCache: updatesCache.getStats(), sampleCodeCache: sampleCodeCache.getStats(), technologyOverviewsCache: technologyOverviewsCache.getStats(), + designContentCache: designContentCache.getStats(), + designResourcesCache: designResourcesCache.getStats(), }; let report = '# Cache Statistics Report\n\n'; @@ -138,6 +156,51 @@ export const toolHandlers: Record = { ); }, + search_apple_design_docs: async (args, server) => { + const validatedArgs = searchAppleDesignDocsSchema.parse(args); + return await server.searchAppleDesignDocs( + validatedArgs.query, + validatedArgs.contentType, + validatedArgs.platform, + validatedArgs.limit, + ); + }, + + get_apple_design_content: async (args, server) => { + const validatedArgs = getAppleDesignContentSchema.parse(args); + return await server.getAppleDesignContent(validatedArgs.url); + }, + + list_apple_design_resources: async (args, server) => { + const validatedArgs = listAppleDesignResourcesSchema.parse(args ?? {}); + return await server.listAppleDesignResources( + validatedArgs.category, + validatedArgs.platform, + validatedArgs.format, + validatedArgs.searchQuery, + validatedArgs.limit, + ); + }, + + download_apple_design_resource: async (args, server) => { + const validatedArgs = downloadAppleDesignResourceSchema.parse(args ?? {}); + return await server.downloadAppleDesignResource( + validatedArgs.resourceId, + validatedArgs.url, + validatedArgs.maxBytes, + ); + }, + + get_apple_design_examples: async (args, server) => { + const validatedArgs = getAppleDesignExamplesSchema.parse(args ?? {}); + return await server.getAppleDesignExamples( + validatedArgs.url, + validatedArgs.resourceId, + validatedArgs.query, + validatedArgs.limit, + ); + }, + list_technologies: async (args, server) => { const validatedArgs = listTechnologiesSchema.parse(args); return await server.listTechnologies( @@ -314,14 +377,14 @@ export async function handleToolCall( toolName: string, args: unknown, server: any, -): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> { +): Promise { try { const handler = toolHandlers[toolName]; if (!handler) { throw new Error(`Unknown tool: ${toolName}`); } - return await handler(args, server) as { content: Array<{ type: string; text: string }>; isError?: boolean }; + return await handler(args, server); } catch (error) { // Return error response for validation errors and unknown tools return { @@ -332,4 +395,4 @@ export async function handleToolCall( isError: true, }; } -} \ No newline at end of file +} diff --git a/src/utils/cache.ts b/src/utils/cache.ts index fa21c0a..cdbca2e 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -172,6 +172,14 @@ export const technologyOverviewsCache = new MemoryCache( CACHE_SIZE.TECHNOLOGY_OVERVIEWS, CACHE_TTL.TECHNOLOGY_OVERVIEWS, ); +export const designContentCache = new MemoryCache( + CACHE_SIZE.DESIGN_CONTENT, + CACHE_TTL.DESIGN_CONTENT, +); +export const designResourcesCache = new MemoryCache( + CACHE_SIZE.DESIGN_RESOURCES, + CACHE_TTL.DESIGN_RESOURCES, +); export const wwdcDataCache = new MemoryCache(100, 30 * 60 * 1000); // 30 minutes TTL /** @@ -295,4 +303,3 @@ export function getCacheInstance( } return cacheInstances.get(name)!; } - diff --git a/src/utils/constants.ts b/src/utils/constants.ts index be9f181..9e2047e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -44,6 +44,8 @@ export const CACHE_TTL = { UPDATES: 30 * 60 * 1000, // 30 minutes SAMPLE_CODE: 2 * 60 * 60 * 1000, // 2 hours TECHNOLOGY_OVERVIEWS: 2 * 60 * 60 * 1000, // 2 hours + DESIGN_CONTENT: 2 * 60 * 60 * 1000, // 2 hours + DESIGN_RESOURCES: 2 * 60 * 60 * 1000, // 2 hours } as const; // Cache Size Configuration @@ -55,6 +57,8 @@ export const CACHE_SIZE = { UPDATES: 100, SAMPLE_CODE: 100, TECHNOLOGY_OVERVIEWS: 100, + DESIGN_CONTENT: 100, + DESIGN_RESOURCES: 20, // Default cache configuration DEFAULT_CACHE_SIZE: 1000, @@ -233,6 +237,9 @@ export const APPLE_URLS = { TECHNOLOGY_OVERVIEWS_INDEX_JSON: 'https://developer.apple.com/tutorials/data/index/technologyoverviews', SAMPLE_CODE_JSON: 'https://developer.apple.com/tutorials/data/documentation/SampleCode.json', SAMPLE_CODE_INDEX_JSON: 'https://developer.apple.com/tutorials/data/index/samplecode', + DESIGN: 'https://developer.apple.com/design/', + DESIGN_RESOURCES: 'https://developer.apple.com/design/resources/', + DESIGN_HIG_JSON: 'https://developer.apple.com/tutorials/data/design/human-interface-guidelines.json', } as const; // WWDC URLs @@ -297,4 +304,4 @@ export const ERROR_MESSAGES = { CACHE_ERROR: 'Cache operation failed, but the request will continue.', VALIDATION_ERROR: 'Input validation failed. Please check your parameters.', SERVICE_UNAVAILABLE: 'Apple Developer Documentation service is temporarily unavailable.', -} as const; \ No newline at end of file +} as const; diff --git a/src/utils/http-client.ts b/src/utils/http-client.ts index 288ef91..55e20d2 100644 --- a/src/utils/http-client.ts +++ b/src/utils/http-client.ts @@ -33,6 +33,10 @@ interface RequestOptions { retries?: number; /** Delay between retries in milliseconds */ retryDelay?: number; + /** Redirect handling mode for fetch */ + redirect?: RequestRedirect; + /** Return manual redirect responses instead of treating them as failed requests */ + allowManualRedirect?: boolean; /** Additional headers to include in the request */ headers?: Record; } @@ -141,6 +145,8 @@ class HttpClient { timeout = REQUEST_CONFIG.TIMEOUT, retries = REQUEST_CONFIG.MAX_RETRIES, retryDelay = REQUEST_CONFIG.RETRY_DELAY, + redirect, + allowManualRedirect = false, headers = {}, } = options; @@ -156,8 +162,9 @@ class HttpClient { return this.fetchWithRetry(url, { method: 'GET', headers: requestHeaders, + redirect, signal: AbortSignal.timeout(timeout), - }, retries, retryDelay); + }, retries, retryDelay, allowManualRedirect); }); } @@ -202,6 +209,7 @@ class HttpClient { options: RequestInit, retries: number, retryDelay: number, + allowManualRedirect: boolean, ): Promise { const startTime = Date.now(); const domain = new URL(url).hostname; @@ -229,15 +237,24 @@ class HttpClient { this.updateStats(response.status, responseTime, true); } + const isAllowedManualRedirect = allowManualRedirect + && response.status >= 300 + && response.status < 400; + + // Manual redirect callers rely on redirect: 'manual' preserving the 3xx status and Location header. // Mark User-Agent success/failure in pool if (pool && currentUserAgent) { - if (response?.ok) { + if (response?.ok || isAllowedManualRedirect) { await pool.markSuccess(currentUserAgent); } else { await pool.markFailure(currentUserAgent, response?.status); } } + if (isAllowedManualRedirect) { + return response; + } + if (!response?.ok) { if (response && response.status === 404) { throw new Error(`${ERROR_MESSAGES.NOT_FOUND} (${response.status})`); @@ -388,6 +405,8 @@ class HttpClient { timeout = REQUEST_CONFIG.TIMEOUT, retries = REQUEST_CONFIG.MAX_RETRIES, retryDelay = REQUEST_CONFIG.RETRY_DELAY, + redirect, + allowManualRedirect = false, headers = {}, } = options; @@ -407,8 +426,9 @@ class HttpClient { const response = await this.fetchWithRetry(url, { method: 'GET', headers: requestHeaders, + redirect, signal: AbortSignal.timeout(timeout), - }, retries, retryDelay); + }, retries, retryDelay, allowManualRedirect); return await response.text(); }); @@ -570,4 +590,4 @@ class HttpClient { } // Export singleton instance -export const httpClient = new HttpClient(); \ No newline at end of file +export const httpClient = new HttpClient(); diff --git a/src/utils/url-converter.ts b/src/utils/url-converter.ts index 57a3241..ae29f66 100644 --- a/src/utils/url-converter.ts +++ b/src/utils/url-converter.ts @@ -22,6 +22,11 @@ export function convertToJsonApiUrl(webUrl: string): string | null { return null; } + const designJsonUrl = convertToDesignJsonApiUrl(webUrl); + if (designJsonUrl) { + return designJsonUrl; + } + let path = urlObj.pathname; // For documentation URLs, format for the JSON API @@ -47,6 +52,34 @@ export function convertToJsonApiUrl(webUrl: string): string | null { } } +/** + * Convert a Human Interface Guidelines web URL to a JSON API URL + * @param webUrl The web URL to convert + * @returns The corresponding HIG JSON API URL + */ +export function convertToDesignJsonApiUrl(webUrl: string): string | null { + try { + const urlObj = new URL(webUrl); + if (urlObj.hostname !== 'developer.apple.com') { + return null; + } + + let path = urlObj.pathname; + if (path.endsWith('/')) { + path = path.slice(0, -1); + } + + if (path !== '/design/human-interface-guidelines' && !path.startsWith('/design/human-interface-guidelines/')) { + return null; + } + + const designPath = path.replace('/design/', ''); + return `https://developer.apple.com/tutorials/data/design/${designPath}.json`; + } catch { + return null; + } +} + /** * Validate if URL is from Apple Developer domain * @param url The URL to validate @@ -61,6 +94,21 @@ export function isValidAppleDeveloperUrl(url: string): boolean { } } +/** + * Validate if URL is an Apple Design URL + * @param url The URL to validate + * @returns True if valid Apple Design URL + */ +export function isAppleDesignUrl(url: string): boolean { + try { + const urlObj = new URL(url); + return urlObj.hostname === 'developer.apple.com' + && (urlObj.pathname === '/design' || urlObj.pathname.startsWith('/design/')); + } catch { + return false; + } +} + /** * Extract API name from URL * @param url The URL to extract name from @@ -72,4 +120,4 @@ export function extractApiNameFromUrl(url: string): string { } catch { return 'Unknown API'; } -} \ No newline at end of file +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 4a58443..d53f19f 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; // Mock all dependencies jest.mock('@modelcontextprotocol/sdk/server/index.js'); @@ -26,9 +31,48 @@ jest.mock('../src/tools/handlers.js', () => ({ content: [{ type: 'text', text: 'Test result' }], }), })); +jest.mock('../src/tools/design-docs.js', () => ({ + handleSearchAppleDesignDocs: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Design search results' }], + }), + handleGetAppleDesignContent: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Design content' }], + }), + handleListAppleDesignResources: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Design resources' }], + }), + handleDownloadAppleDesignResource: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Downloaded design resource' }], + }), + handleGetAppleDesignExamples: jest.fn().mockResolvedValue({ + content: [{ type: 'image', data: 'aW1hZ2U=', mimeType: 'image/png' }], + }), + listCachedDesignResources: jest.fn().mockResolvedValue({ + resources: [ + { + uri: 'apple-design://cache/test/example.zip', + name: 'example.zip', + mimeType: 'application/zip', + }, + ], + }), + readCachedDesignResource: jest.fn().mockResolvedValue({ + contents: [ + { + uri: 'apple-design://cache/test/example.zip', + mimeType: 'application/zip', + blob: 'emlw', + }, + ], + }), +})); // Import after mocks import { handleToolCall } from '../src/tools/handlers.js'; +import { + listCachedDesignResources, + readCachedDesignResource, +} from '../src/tools/design-docs.js'; describe('AppleDeveloperDocsMCPServer', () => { let mockServer: any; @@ -66,6 +110,7 @@ describe('AppleDeveloperDocsMCPServer', () => { { capabilities: { tools: {}, + resources: {}, }, } ); @@ -74,8 +119,8 @@ describe('AppleDeveloperDocsMCPServer', () => { it('should setup tools and error handling', () => { new AppleDeveloperDocsMCPServer(); - // Should register two request handlers - expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(2); + // Should register tools and resources request handlers + expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(4); // First call should be for ListToolsRequestSchema expect(mockServer.setRequestHandler).toHaveBeenNthCalledWith( @@ -90,6 +135,18 @@ describe('AppleDeveloperDocsMCPServer', () => { CallToolRequestSchema, expect.any(Function) ); + + expect(mockServer.setRequestHandler).toHaveBeenNthCalledWith( + 3, + ListResourcesRequestSchema, + expect.any(Function) + ); + + expect(mockServer.setRequestHandler).toHaveBeenNthCalledWith( + 4, + ReadResourceRequestSchema, + expect.any(Function) + ); }); }); @@ -187,6 +244,50 @@ describe('AppleDeveloperDocsMCPServer', () => { }); }); + describe('resource listing and reading', () => { + it('should list cached Apple Design resources', async () => { + new AppleDeveloperDocsMCPServer(); + + const listResourcesHandler = mockServer.setRequestHandler.mock.calls[2][1]; + const result = await listResourcesHandler({}); + + expect(listCachedDesignResources).toHaveBeenCalledWith(); + expect(result).toEqual({ + resources: [ + { + uri: 'apple-design://cache/test/example.zip', + name: 'example.zip', + mimeType: 'application/zip', + }, + ], + }); + }); + + it('should read cached Apple Design resources as blob contents', async () => { + new AppleDeveloperDocsMCPServer(); + + const readResourcesHandler = mockServer.setRequestHandler.mock.calls[3][1]; + const result = await readResourcesHandler({ + params: { + uri: 'apple-design://cache/test/example.zip', + }, + }); + + expect(readCachedDesignResource).toHaveBeenCalledWith( + 'apple-design://cache/test/example.zip', + ); + expect(result).toEqual({ + contents: [ + { + uri: 'apple-design://cache/test/example.zip', + mimeType: 'application/zip', + blob: 'emlw', + }, + ], + }); + }); + }); + describe('run method', () => { it('should create transport and start server', async () => { const server = new AppleDeveloperDocsMCPServer(); @@ -258,5 +359,18 @@ describe('AppleDeveloperDocsMCPServer', () => { expect(server.getSampleCode).toBeDefined(); expect(typeof server.getSampleCode).toBe('function'); }); + + it('should have Apple Design public methods', () => { + expect(server.searchAppleDesignDocs).toBeDefined(); + expect(typeof server.searchAppleDesignDocs).toBe('function'); + expect(server.getAppleDesignContent).toBeDefined(); + expect(typeof server.getAppleDesignContent).toBe('function'); + expect(server.listAppleDesignResources).toBeDefined(); + expect(typeof server.listAppleDesignResources).toBe('function'); + expect(server.downloadAppleDesignResource).toBeDefined(); + expect(typeof server.downloadAppleDesignResource).toBe('function'); + expect(server.getAppleDesignExamples).toBeDefined(); + expect(typeof server.getAppleDesignExamples).toBe('function'); + }); }); -}); \ No newline at end of file +}); diff --git a/tests/response-format.test.ts b/tests/response-format.test.ts index 2091dc5..7d4c2e8 100644 --- a/tests/response-format.test.ts +++ b/tests/response-format.test.ts @@ -19,6 +19,7 @@ jest.mock('../src/utils/wwdc-data-source.js', () => ({ })); import AppleDeveloperDocsMCPServer from '../src/index.js'; +import { handleDownloadAppleDesignResource } from '../src/tools/design-docs.js'; // Mock external dependencies jest.mock('../src/utils/http-client.js', () => ({ @@ -95,6 +96,60 @@ jest.mock('../src/tools/doc-fetcher.js', () => ({ }) })); +jest.mock('../src/tools/design-docs.js', () => ({ + handleSearchAppleDesignDocs: jest.fn().mockResolvedValue({ + content: [{ + type: 'text', + text: 'Mock Apple Design search results' + }] + }), + handleGetAppleDesignContent: jest.fn().mockResolvedValue({ + content: [{ + type: 'text', + text: 'Mock Apple Design content' + }] + }), + handleListAppleDesignResources: jest.fn().mockResolvedValue({ + content: [{ + type: 'text', + text: 'Mock Apple Design resources' + }] + }), + handleDownloadAppleDesignResource: jest.fn().mockResolvedValue({ + content: [ + { + type: 'text', + text: 'Mock downloaded Apple Design resource' + }, + { + type: 'resource_link', + uri: 'apple-design://cache/test/example.zip', + name: 'example.zip', + mimeType: 'application/zip' + } + ] + }), + handleGetAppleDesignExamples: jest.fn().mockResolvedValue({ + content: [ + { + type: 'text', + text: 'Mock Apple Design examples' + }, + { + type: 'image', + data: Buffer.from('image').toString('base64'), + mimeType: 'image/png' + } + ] + }), + listCachedDesignResources: jest.fn().mockResolvedValue({ + resources: [] + }), + readCachedDesignResource: jest.fn().mockResolvedValue({ + contents: [] + }) +})); + describe('Response Format Validation', () => { let server: AppleDeveloperDocsMCPServer; @@ -108,8 +163,10 @@ describe('Response Format Validation', () => { * { * content: [ * { - * type: 'text', - * text: string + * type: 'text' | 'image' | 'resource_link' | 'resource', + * text?: string, + * data?: string, + * uri?: string * } * ], * isError?: boolean @@ -122,13 +179,30 @@ describe('Response Format Validation', () => { response.content.forEach((item: any) => { expect(item).toHaveProperty('type'); - expect(item.type).toBe('text'); - expect(item).toHaveProperty('text'); - expect(typeof item.text).toBe('string'); - - // Ensure text is not a nested object (the main issue we're preventing) - expect(typeof item.text).not.toBe('object'); - expect(item.text).not.toHaveProperty('content'); + expect(['text', 'image', 'resource_link', 'resource']).toContain(item.type); + + if (item.type === 'text') { + expect(item).toHaveProperty('text'); + expect(typeof item.text).toBe('string'); + + // Ensure text is not a nested object (the main issue we're preventing) + expect(typeof item.text).not.toBe('object'); + expect(item.text).not.toHaveProperty('content'); + } + + if (item.type === 'image') { + expect(item).toHaveProperty('data'); + expect(item).toHaveProperty('mimeType'); + expect(typeof item.data).toBe('string'); + expect(typeof item.mimeType).toBe('string'); + } + + if (item.type === 'resource_link') { + expect(item).toHaveProperty('uri'); + expect(item).toHaveProperty('name'); + expect(typeof item.uri).toBe('string'); + expect(typeof item.name).toBe('string'); + } }); }; @@ -192,6 +266,26 @@ describe('Response Format Validation', () => { name: 'getSampleCode', method: () => server.getSampleCode(), }, + { + name: 'searchAppleDesignDocs', + method: () => server.searchAppleDesignDocs('layout'), + }, + { + name: 'getAppleDesignContent', + method: () => server.getAppleDesignContent('https://developer.apple.com/design/human-interface-guidelines/layout'), + }, + { + name: 'listAppleDesignResources', + method: () => server.listAppleDesignResources(), + }, + { + name: 'downloadAppleDesignResource', + method: () => server.downloadAppleDesignResource(undefined, 'https://developer.apple.com/design/downloads/templates.zip'), + }, + { + name: 'getAppleDesignExamples', + method: () => server.getAppleDesignExamples('https://developer.apple.com/design/human-interface-guidelines/layout'), + }, ]; toolTests.forEach(({ name, method }) => { @@ -202,6 +296,15 @@ describe('Response Format Validation', () => { expect(typeof response.content[0].text).toBe('string'); }); }); + + it('should delegate Apple Design URLs from getAppleDocContent without nesting', async () => { + const response = await server.getAppleDocContent( + 'https://developer.apple.com/design/human-interface-guidelines/layout' + ); + + validateResponseFormat(response); + expect(response.content[0].text).toContain('Mock Apple Design content'); + }); }); describe('Error response format consistency', () => { @@ -224,6 +327,18 @@ describe('Response Format Validation', () => { validateResponseFormat(response); expect(response.isError).toBe(true); }); + + it('should format Apple Design handler errors without nesting', async () => { + (handleDownloadAppleDesignResource as jest.Mock).mockRejectedValueOnce( + new Error('A resourceId or direct URL is required.'), + ); + + const response = await server.downloadAppleDesignResource(); + + validateResponseFormat(response); + expect(response.isError).toBe(true); + expect(response.content[0].text).toContain('A resourceId or direct URL is required.'); + }); }); describe('Regression tests for nested response issue', () => { @@ -353,4 +468,4 @@ describe('Response Format Validation', () => { expect(response.content[0].text).toBeDefined(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/tools/design-docs.test.ts b/tests/tools/design-docs.test.ts new file mode 100644 index 0000000..13d8605 --- /dev/null +++ b/tests/tools/design-docs.test.ts @@ -0,0 +1,1326 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + clearDesignResourceCacheForTesting, + formatAppleDesignDocument, + handleDownloadAppleDesignResource, + handleGetAppleDesignContent, + handleGetAppleDesignExamples, + handleSearchAppleDesignDocs, + listCachedDesignResources, + parseAppleDesignHtmlPage, + parseDesignResourcesHtml, + readCachedDesignResource, +} from '../../src/tools/design-docs.js'; +import { httpClient } from '../../src/utils/http-client.js'; + +jest.mock('../../src/utils/http-client.js', () => ({ + httpClient: { + get: jest.fn(), + getJson: jest.fn(), + getText: jest.fn(), + }, +})); + +const SAMPLE_HIG_DOCUMENT = { + metadata: { + title: 'Layout', + role: 'article', + abstract: [ + { + type: 'text', + text: 'Place content in ways that help people understand your interface.', + }, + ], + customMetadata: { + 'supported-platforms': [ + 'iOS', + 'iPadOS', + 'macOS', + ], + 'alert-text': 'Use layout guidance with platform conventions.', + 'alert-date': '2026-06-09', + }, + }, + abstract: [ + { + type: 'text', + text: 'Arrange views so people can understand and interact with them.', + }, + ], + references: { + 'doc://image/layout-hero': { + type: 'image', + alt: 'A layout example', + variants: [ + { + url: '/assets/elements/icons/layout-hero.png', + }, + ], + }, + 'doc://topic/color': { + title: 'Color', + url: '/design/human-interface-guidelines/color', + abstract: [ + { + type: 'text', + text: 'Use color consistently.', + }, + ], + }, + }, + primaryContentSections: [ + { + kind: 'content', + content: [ + { + type: 'heading', + text: 'Best practices', + level: 2, + }, + { + type: 'paragraph', + inlineContent: [ + { + type: 'text', + text: 'Use a ', + }, + { + type: 'strong', + inlineContent: [ + { + type: 'text', + text: 'consistent', + }, + ], + }, + { + type: 'text', + text: ' grid.', + }, + ], + }, + { + type: 'image', + identifier: 'doc://image/layout-hero', + }, + { + type: 'unorderedList', + items: [ + { + content: [ + { + type: 'paragraph', + inlineContent: [ + { + type: 'text', + text: 'Align related controls.', + }, + ], + }, + ], + }, + ], + }, + { + type: 'table', + header: [ + { + inlineContent: [ + { + type: 'text', + text: 'Platform', + }, + ], + }, + { + inlineContent: [ + { + type: 'text', + text: 'Spacing', + }, + ], + }, + ], + rows: [ + { + cells: [ + { + inlineContent: [ + { + type: 'text', + text: 'iOS', + }, + ], + }, + { + inlineContent: [ + { + type: 'text', + text: '8 pt', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + topicSections: [ + { + title: 'Foundations', + identifiers: [ + 'doc://topic/color', + ], + }, + ], + sections: [ + { + title: 'Change log', + kind: 'changes', + items: [ + { + date: '2026-06-09', + content: [ + { + type: 'paragraph', + inlineContent: [ + { + type: 'text', + text: 'Updated platform guidance.', + }, + ], + }, + ], + }, + ], + }, + ], +}; + +const SAMPLE_RESOURCES_HTML = ` +
+
+

Design templates

+

iOS 18 and iPadOS 18

+
+ iOS template +
iOS 18 and iPadOS 18
+ Figma + Sketch Library + Download +

Requires macOS Sonoma or later.

+

Layout

+
+
+
+

Fonts

+

System fonts

+
+ +
SF Pro
+ Download +
+
+
+`; + +let temporaryCacheDirectory: string | undefined; + +function createResponse( + data: Buffer, + contentType: string | undefined, + contentLength?: number, +): Response { + const headers = new Headers(); + if (contentType) { + headers.set('content-type', contentType); + } + if (contentLength !== undefined) { + headers.set('content-length', String(contentLength)); + } else { + headers.set('content-length', String(data.length)); + } + + return new Response(data, { + status: 200, + headers, + }); +} + +function createJsonResponse(data: unknown): Response { + return createResponse(Buffer.from(JSON.stringify(data)), 'application/json'); +} + +function createShortHash(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 12); +} + +function createResponseWithUrl( + data: Buffer, + contentType: string | undefined, + url: string, +): Response { + const response = createResponse(data, contentType); + Object.defineProperty(response, 'url', { + value: url, + }); + return response; +} + +function createRedirectResponse(location: string): Response { + return new Response(null, { + status: 302, + headers: { + location, + }, + }); +} + +function createCancelableRedirectResponse(location: string, cancel: () => Promise): Response { + return { + status: 302, + headers: new Headers({ + location, + }), + body: { + cancel, + }, + } as unknown as Response; +} + +beforeEach(async () => { + temporaryCacheDirectory = await mkdtemp(path.join(tmpdir(), 'apple-design-test-')); + process.env.APPLE_DOCS_MCP_CACHE_DIR = temporaryCacheDirectory; + clearDesignResourceCacheForTesting(); + jest.clearAllMocks(); +}); + +afterEach(async () => { + clearDesignResourceCacheForTesting(); + delete process.env.APPLE_DOCS_MCP_CACHE_DIR; + delete process.env.APPLE_DOCS_MCP_CACHE_MAX_BYTES; + + if (temporaryCacheDirectory) { + await rm(temporaryCacheDirectory, { + force: true, + recursive: true, + }); + } +}); + +describe('Apple Design document formatting', () => { + it('should reject non-Apple Design content URLs before fetching', async () => { + await expect(handleGetAppleDesignContent({ + url: 'https://example.com/design/', + })).rejects.toThrow('Apple Design content URLs'); + + expect(httpClient.getText).not.toHaveBeenCalled(); + expect(httpClient.getJson).not.toHaveBeenCalled(); + }); + + it('should follow allowed Apple Design content redirects manually', async () => { + const html = ` +
+

Design Resources

+

Download templates.

+
+ `; + const cancelBody = jest.fn(async () => undefined); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createCancelableRedirectResponse( + 'https://developer.apple.com/design/resources/', + cancelBody, + )) + .mockResolvedValueOnce(createResponse(Buffer.from(html), 'text/html')); + + const result = await handleGetAppleDesignContent({ + url: 'https://developer.apple.com/design/', + }); + + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('# Design Resources'), + }); + expect(httpClient.get).toHaveBeenNthCalledWith( + 1, + 'https://developer.apple.com/design/', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + expect(httpClient.get).toHaveBeenNthCalledWith( + 2, + 'https://developer.apple.com/design/resources/', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + expect(cancelBody).toHaveBeenCalledTimes(1); + }); + + it('should reject Apple Design content redirects outside the content allowlist', async () => { + (httpClient.get as jest.Mock).mockResolvedValue( + createRedirectResponse('https://example.com/design/'), + ); + + await expect(handleGetAppleDesignContent({ + url: 'https://developer.apple.com/design/', + })).rejects.toThrow('outside the Apple Design content allowlist'); + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); + + it('should reject oversized Apple Design HTML before reading the body', async () => { + const arrayBuffer = jest.fn(); + const cancelBody = jest.fn(async () => undefined); + const oversizedResponse = { + headers: new Headers({ + 'content-length': String((20 * 1024 * 1024) + 1), + 'content-type': 'text/html', + }), + status: 200, + body: { + cancel: cancelBody, + }, + arrayBuffer, + } as unknown as Response; + (httpClient.get as jest.Mock).mockResolvedValue(oversizedResponse); + + await expect(handleGetAppleDesignContent({ + url: 'https://developer.apple.com/design/', + })).rejects.toThrow('Apple Design content page exceeds'); + expect(arrayBuffer).not.toHaveBeenCalled(); + expect(cancelBody).toHaveBeenCalledTimes(1); + }); + + it('should clamp invalid HIG heading levels', () => { + const document = { + metadata: { + title: 'Heading levels', + }, + primaryContentSections: [ + { + kind: 'content', + content: [ + { + type: 'heading', + text: 'Negative heading', + level: -1, + }, + { + type: 'heading', + text: 'Large heading', + level: 20, + }, + ], + }, + ], + }; + + const result = formatAppleDesignDocument( + document, + 'https://developer.apple.com/design/human-interface-guidelines/layout', + ); + + expect(result).toContain('# Negative heading'); + expect(result).toContain('###### Large heading'); + }); + + it('should reject oversized Apple Design JSON before reading the body', async () => { + const arrayBuffer = jest.fn(); + const oversizedResponse = { + headers: new Headers({ + 'content-length': String((20 * 1024 * 1024) + 1), + 'content-type': 'application/json', + }), + status: 200, + body: null, + arrayBuffer, + } as unknown as Response; + (httpClient.get as jest.Mock).mockResolvedValue(oversizedResponse); + + await expect(handleGetAppleDesignContent({ + url: 'https://developer.apple.com/design/human-interface-guidelines/layout', + })).rejects.toThrow('Apple Design JSON exceeds'); + expect(arrayBuffer).not.toHaveBeenCalled(); + }); + + it('should format HIG JSON content with images, tables, links, platforms, and change logs', () => { + const result = formatAppleDesignDocument( + SAMPLE_HIG_DOCUMENT, + 'https://developer.apple.com/design/human-interface-guidelines/layout', + ); + + expect(result).toContain('# Layout'); + expect(result).toContain('Arrange views so people can understand and interact with them.'); + expect(result).toContain('## Supported Platforms'); + expect(result).toContain('iOS'); + expect(result).toContain('## Alert'); + expect(result).toContain('Use layout guidance with platform conventions.'); + expect(result).toContain('**consistent** grid.'); + expect(result).toContain('![A layout example](https://developer.apple.com/assets/elements/icons/layout-hero.png)'); + expect(result).toContain('| Platform | Spacing |'); + expect(result).toContain('- [Color](https://developer.apple.com/design/human-interface-guidelines/color)'); + expect(result).toContain('## Change Log'); + expect(result).toContain('2026-06-09'); + }); + + it('should parse Apple Design HTML cards when JSON is not available', () => { + const html = ` +
+

Apple Design

+

Design great apps and games.

+ +

Design resources

+

Download templates, components, and tools.

+
+
+ `; + + const result = parseAppleDesignHtmlPage(html, 'https://developer.apple.com/design/'); + + expect(result).toContain('# Apple Design'); + expect(result).toContain('Design great apps and games.'); + expect(result).toContain('[Design resources](https://developer.apple.com/design/resources/)'); + }); +}); + +describe('Apple Design search', () => { + it('should apply platform filters to HIG search results', async () => { + (httpClient.get as jest.Mock).mockResolvedValue(createJsonResponse(SAMPLE_HIG_DOCUMENT)); + + const result = await handleSearchAppleDesignDocs({ + query: 'Layout', + contentType: 'hig', + platform: 'watchOS', + }); + + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('No Apple Design results found.'), + }); + expect(httpClient.get).toHaveBeenCalledWith( + 'https://developer.apple.com/tutorials/data/design/human-interface-guidelines.json', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + }); +}); + +describe('Apple Design Resources parser', () => { + it('should extract templates, fonts, notes, previews, formats, and related HIG links', () => { + const resources = parseDesignResourcesHtml( + SAMPLE_RESOURCES_HTML, + 'https://developer.apple.com/design/resources/', + ); + + expect(resources).toHaveLength(4); + expect(resources[0]).toMatchObject({ + resourceId: `design-resource:design-templates:ios-18-and-ipados-18:ios-18-and-ipados-18:figma:${createShortHash('https://www.figma.com/community/file/123')}`, + category: 'Design templates', + platform: 'iOS 18 and iPadOS 18', + title: 'iOS 18 and iPadOS 18', + format: 'figma', + downloadLabel: 'Figma', + downloadUrl: 'https://www.figma.com/community/file/123', + note: 'Requires macOS Sonoma or later. Layout', + previewImageUrl: 'https://developer.apple.com/assets/elements/icons/ios-template.png', + }); + expect(resources[0].relatedGuidelineLinks).toEqual([ + 'https://developer.apple.com/design/human-interface-guidelines/layout', + ]); + expect(resources[1]).toMatchObject({ + format: 'sketch', + downloadUrl: 'sketch://add-library?url=https://example.com/library.sketch', + }); + expect(resources[2]).toMatchObject({ + format: 'dmg', + downloadUrl: 'https://developer.apple.com/design/downloads/iOS-18.dmg', + }); + expect(resources[3]).toMatchObject({ + category: 'Fonts', + format: 'zip', + title: 'SF Pro', + }); + }); + + it('should keep resource IDs stable when same-label resources reorder', () => { + const firstHtml = ` +
+
+

Design templates

+

iOS

+
+
Alpha template
+ Download +
+
+
Beta template
+ Download +
+
+
+ `; + const secondHtml = ` +
+
+

Design templates

+

iOS

+
+
Beta template
+ Download +
+
+
Alpha template
+ Download +
+
+
+ `; + + const firstResources = parseDesignResourcesHtml( + firstHtml, + 'https://developer.apple.com/design/resources/', + ); + const secondResources = parseDesignResourcesHtml( + secondHtml, + 'https://developer.apple.com/design/resources/', + ); + const firstAlpha = firstResources.find(resource => resource.downloadUrl.endsWith('/alpha.zip')); + const secondAlpha = secondResources.find(resource => resource.downloadUrl.endsWith('/alpha.zip')); + const firstBeta = firstResources.find(resource => resource.downloadUrl.endsWith('/beta.zip')); + const secondBeta = secondResources.find(resource => resource.downloadUrl.endsWith('/beta.zip')); + + expect(firstAlpha?.resourceId).toBe(secondAlpha?.resourceId); + expect(firstBeta?.resourceId).toBe(secondBeta?.resourceId); + expect(firstAlpha?.resourceId).not.toBe(firstBeta?.resourceId); + expect(firstAlpha?.resourceId).toContain(createShortHash('https://developer.apple.com/design/downloads/alpha.zip')); + }); + + it('should suffix resource IDs for exact duplicate resource entries', () => { + const html = ` +
+
+

Design templates

+

iOS

+
+
Duplicate template
+ Download +
+
+
Duplicate template
+ Download +
+
+
+ `; + + const resources = parseDesignResourcesHtml( + html, + 'https://developer.apple.com/design/resources/', + ); + const baseResourceId = `design-resource:design-templates:ios:duplicate-template:download:${ + createShortHash('https://developer.apple.com/design/downloads/duplicate.zip') + }`; + + expect(resources.map(resource => resource.resourceId)).toEqual([ + baseResourceId, + `${baseResourceId}:2`, + ]); + }); +}); + +describe('Apple Design downloads and resources', () => { + it('should download direct images, return image content, and expose BlobResourceContents', async () => { + const imageBytes = Buffer.from('image-bytes'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(imageBytes, 'image/png'), + ); + + const result = await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/images/example.png', + }); + + const imageContent = result.content.find(content => content.type === 'image'); + const resourceLink = result.content.find(content => content.type === 'resource_link'); + + expect(imageContent).toMatchObject({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }); + expect(resourceLink).toMatchObject({ + type: 'resource_link', + mimeType: 'image/png', + name: 'example.png', + }); + const imageHash = createHash('sha256').update(imageBytes).digest('hex'); + expect(resourceLink?.uri).toContain(`apple-design://cache/${imageHash}/${imageHash}-`); + expect(resourceLink?.uri).not.toBe(`apple-design://cache/${imageHash}/example.png`); + + const resources = await listCachedDesignResources(); + expect(resources.resources).toHaveLength(1); + + const resource = await readCachedDesignResource(resourceLink?.uri ?? ''); + expect(resource.contents[0]).toMatchObject({ + uri: resourceLink?.uri, + mimeType: 'image/png', + blob: imageBytes.toString('base64'), + }); + }); + + it('should read persisted resource URIs after process-local cache state is cleared', async () => { + const imageBytes = Buffer.from('persisted-image-bytes'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(imageBytes, 'image/png'), + ); + + const result = await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/images/persisted.png', + }); + const resourceLink = result.content.find(content => content.type === 'resource_link'); + const uri = resourceLink?.uri ?? ''; + + clearDesignResourceCacheForTesting(); + + const resource = await readCachedDesignResource(uri); + expect(resource.contents[0]).toMatchObject({ + uri, + mimeType: 'image/png', + blob: imageBytes.toString('base64'), + }); + + const resources = await listCachedDesignResources(); + expect(resources.resources).toEqual([ + expect.objectContaining({ + uri, + mimeType: 'image/png', + name: 'persisted.png', + }), + ]); + }); + + it('should avoid inlining large downloaded images', async () => { + const imageBytes = Buffer.alloc((10 * 1024 * 1024) + 1, 1); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(imageBytes, 'image/png'), + ); + + const result = await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/images/large-preview.png', + maxBytes: imageBytes.length, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Downloaded Apple Design resource'), + }), + expect.objectContaining({ + type: 'resource_link', + mimeType: 'image/png', + name: 'large-preview.png', + }), + ]); + expect(result.content.some(content => content.type === 'image')).toBe(false); + }); + + it('should return resource links for non-image downloads with MIME fallback', async () => { + const archiveBytes = Buffer.from('zip-bytes'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(archiveBytes, undefined), + ); + + const result = await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Downloaded Apple Design resource'), + }), + expect.objectContaining({ + type: 'resource_link', + mimeType: 'application/zip', + name: 'templates.zip', + }), + ]); + }); + + it('should reject blocked download domains', async () => { + await expect(handleDownloadAppleDesignResource({ + url: 'https://example.com/design-kit.zip', + })).rejects.toThrow('not allowed'); + }); + + it('should follow allowed Apple download redirects manually', async () => { + const archiveBytes = Buffer.from('zip-bytes'); + const cancelBody = jest.fn(async () => undefined); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createCancelableRedirectResponse( + 'https://devimages-cdn.apple.com/design/templates.zip', + cancelBody, + )) + .mockResolvedValueOnce(createResponse(archiveBytes, 'application/zip')); + + const result = await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Downloaded Apple Design resource'), + }), + expect.objectContaining({ + type: 'resource_link', + mimeType: 'application/zip', + name: 'templates.zip', + }), + ]); + expect(httpClient.get).toHaveBeenNthCalledWith( + 1, + 'https://developer.apple.com/design/downloads/templates.zip', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + expect(httpClient.get).toHaveBeenNthCalledWith( + 2, + 'https://devimages-cdn.apple.com/design/templates.zip', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + expect(cancelBody).toHaveBeenCalledTimes(1); + }); + + it('should reject download redirects outside the Apple allowlist before fetching them', async () => { + (httpClient.get as jest.Mock).mockResolvedValue( + createRedirectResponse('https://example.com/templates.zip'), + ); + + await expect(handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + })).rejects.toThrow('outside the Apple Design allowlist'); + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); + + it('should reject download responses whose final URL is outside the Apple allowlist', async () => { + const archiveBytes = Buffer.from('zip-bytes'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponseWithUrl(archiveBytes, 'application/zip', 'https://example.com/templates.zip'), + ); + + await expect(handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + })).rejects.toThrow('outside the Apple Design allowlist'); + expect(httpClient.get).toHaveBeenCalledWith( + 'https://developer.apple.com/design/downloads/templates.zip', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + }); + + it('should reject oversized downloads before reading the body', async () => { + const archiveBytes = Buffer.from('zip-bytes'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(archiveBytes, 'application/zip', 11), + ); + + await expect(handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + maxBytes: 10, + })).rejects.toThrow('exceeds'); + }); + + it('should reuse duplicate cache hits for the same URL', async () => { + const archiveBytes = Buffer.from('zip-bytes'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(archiveBytes, 'application/zip'), + ); + + const firstResult = await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + }); + const secondResult = await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + }); + + const firstLink = firstResult.content.find(content => content.type === 'resource_link'); + const secondLink = secondResult.content.find(content => content.type === 'resource_link'); + + expect(httpClient.get).toHaveBeenCalledTimes(1); + expect(firstLink?.uri).toBe(secondLink?.uri); + }); + + it('should reuse in-flight duplicate downloads for the same URL', async () => { + const archiveBytes = Buffer.from('zip-bytes'); + let resolveResponse: (response: Response) => void = () => undefined; + const responsePromise = new Promise((resolve) => { + resolveResponse = resolve; + }); + (httpClient.get as jest.Mock).mockReturnValue(responsePromise); + + const firstPromise = handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + }); + const secondPromise = handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + }); + + resolveResponse(createResponse(archiveBytes, 'application/zip')); + const [firstResult, secondResult] = await Promise.all([firstPromise, secondPromise]); + const firstLink = firstResult.content.find(content => content.type === 'resource_link'); + const secondLink = secondResult.content.find(content => content.type === 'resource_link'); + + expect(httpClient.get).toHaveBeenCalledTimes(1); + expect(firstLink?.uri).toBe(secondLink?.uri); + }); + + it('should enforce smaller maxBytes limits on duplicate cache hits', async () => { + const archiveBytes = Buffer.from('zip-bytes'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(archiveBytes, 'application/zip'), + ); + + await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + }); + + await expect(handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/templates.zip', + maxBytes: 4, + })).rejects.toThrow('exceeds'); + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); + + it('should reject downloads that exceed the aggregate cache limit', async () => { + process.env.APPLE_DOCS_MCP_CACHE_MAX_BYTES = '10'; + const firstArchiveBytes = Buffer.from('123456'); + const secondArchiveBytes = Buffer.from('abcdef'); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createResponse(firstArchiveBytes, 'application/zip')) + .mockResolvedValueOnce(createResponse(secondArchiveBytes, 'application/zip')); + + await handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/one.zip', + }); + + await expect(handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/two.zip', + })).rejects.toThrow('cache limit'); + }); + + it('should count existing cache-directory files toward the aggregate cache limit', async () => { + process.env.APPLE_DOCS_MCP_CACHE_MAX_BYTES = '10'; + await writeFile(path.join(temporaryCacheDirectory ?? '', 'old-download.zip'), '12345678'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(Buffer.from('abc'), 'application/zip'), + ); + + await expect(handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/new.zip', + })).rejects.toThrow('cache limit'); + }); + + it('should enforce the aggregate cache limit across concurrent downloads', async () => { + process.env.APPLE_DOCS_MCP_CACHE_MAX_BYTES = '10'; + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createResponse(Buffer.from('123456'), 'application/zip')) + .mockResolvedValueOnce(createResponse(Buffer.from('abcdef'), 'application/zip')); + + const results = await Promise.allSettled([ + handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/concurrent-one.zip', + }), + handleDownloadAppleDesignResource({ + url: 'https://developer.apple.com/design/downloads/concurrent-two.zip', + }), + ]); + + expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1); + expect(results.filter(result => result.status === 'rejected')).toHaveLength(1); + }); +}); + +describe('Apple Design examples', () => { + it('should reject non-Apple example page URLs before fetching', async () => { + await expect(handleGetAppleDesignExamples({ + url: 'https://example.com/design/example-page', + })).rejects.toThrow('Apple Design content URLs'); + + expect(httpClient.getText).not.toHaveBeenCalled(); + expect(httpClient.get).not.toHaveBeenCalled(); + }); + + it('should reject direct image URLs from non-Apple hosts before fetching', async () => { + await expect(handleGetAppleDesignExamples({ + url: 'https://example.com/design/example.png', + })).rejects.toThrow('allowed Apple host'); + + expect(httpClient.getText).not.toHaveBeenCalled(); + expect(httpClient.get).not.toHaveBeenCalled(); + }); + + it('should reject unknown resource IDs for examples', async () => { + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(Buffer.from(''), 'text/html'), + ); + + await expect(handleGetAppleDesignExamples({ + resourceId: 'missing-resource-id', + })).rejects.toThrow('Unknown Apple Design resourceId'); + + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); + + it('should return HIG image references as MCP image content blocks', async () => { + const imageBytes = Buffer.from('hig-image'); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createJsonResponse(SAMPLE_HIG_DOCUMENT)) + .mockResolvedValueOnce(createResponse(imageBytes, 'image/png')); + + const result = await handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/human-interface-guidelines/layout', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Apple Design Examples'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + }); + + it('should return direct Apple CDN images without path extensions', async () => { + const imageBytes = Buffer.from('cdn-preview-image'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(imageBytes, 'image/png'), + ); + + const result = await handleGetAppleDesignExamples({ + url: 'https://docs-assets.developer.apple.com/design/preview', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Apple Design Examples'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + }); + + it('should reject Apple Design example page redirects outside the content allowlist', async () => { + (httpClient.get as jest.Mock).mockResolvedValue( + createRedirectResponse('https://example.com/design/example-page'), + ); + + await expect(handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/get-started/', + })).rejects.toThrow('outside the Apple Design content allowlist'); + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); + + it('should keep trying example candidates until limit fetchable images are found', async () => { + const html = ` +
+ + +
+ `; + const imageBytes = Buffer.from('valid-preview'); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createResponse(Buffer.from(html), 'text/html')) + .mockResolvedValueOnce(createResponse(Buffer.from('not-image'), 'text/plain')) + .mockResolvedValueOnce(createResponse(imageBytes, 'image/png')); + + const result = await handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/get-started/', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Found 1 image example.'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + expect(httpClient.get).toHaveBeenCalledTimes(3); + }); + + it('should cap query-derived preview fetches by the requested limit', async () => { + const html = ` +
+
+

Design templates

+

iOS

+
+ +
Template one
+ Download +
+
+ +
Template two
+ Download +
+
+
+ `; + const imageBytes = Buffer.from('template-one'); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createResponse(Buffer.from(html), 'text/html')) + .mockResolvedValueOnce(createResponse(imageBytes, 'image/png')); + + const result = await handleGetAppleDesignExamples({ + query: 'template', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Found 1 image example.'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + expect(httpClient.get).toHaveBeenCalledTimes(2); + }); + + it('should skip failed discovered image candidates and keep trying later candidates', async () => { + const html = ` +
+ + +
+ `; + const imageBytes = Buffer.from('fresh-preview'); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createResponse(Buffer.from(html), 'text/html')) + .mockRejectedValueOnce(new Error('HTTP 404: Not Found')) + .mockResolvedValueOnce(createResponse(imageBytes, 'image/png')); + + const result = await handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/get-started/', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Found 1 image example.'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + expect(httpClient.get).toHaveBeenCalledTimes(3); + }); + + it('should extract HIG image examples from tabs', async () => { + const imageBytes = Buffer.from('tab-image'); + const documentWithTabs = { + ...SAMPLE_HIG_DOCUMENT, + references: {}, + primaryContentSections: [ + { + kind: 'content', + content: [ + { + type: 'tabNavigator', + tabs: [ + { + title: 'iOS', + content: [ + { + type: 'image', + url: '/assets/elements/icons/tab-example.png', + alt: 'A tabbed image', + }, + ], + }, + ], + }, + ], + }, + ], + }; + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createJsonResponse(documentWithTabs)) + .mockResolvedValueOnce(createResponse(imageBytes, 'image/png')); + + const result = await handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/human-interface-guidelines/layout', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Apple Design Examples'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + }); + + it('should return direct Apple image URLs as MCP image content blocks', async () => { + const imageBytes = Buffer.from('preview-image'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponse(imageBytes, 'image/png'), + ); + + const result = await handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/images/example.png', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Apple Design Examples'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + expect(httpClient.get).toHaveBeenCalledWith( + 'https://developer.apple.com/design/images/example.png', + expect.any(Object), + ); + }); + + it('should follow allowed Apple image redirects manually', async () => { + const imageBytes = Buffer.from('preview-image'); + (httpClient.get as jest.Mock) + .mockResolvedValueOnce(createRedirectResponse('https://docs-assets.developer.apple.com/design/example.png')) + .mockResolvedValueOnce(createResponse(imageBytes, 'image/png')); + + const result = await handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/images/example.png', + limit: 1, + }); + + expect(result.content).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Apple Design Examples'), + }), + expect.objectContaining({ + type: 'image', + data: imageBytes.toString('base64'), + mimeType: 'image/png', + }), + ]); + expect(httpClient.get).toHaveBeenNthCalledWith( + 1, + 'https://developer.apple.com/design/images/example.png', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + expect(httpClient.get).toHaveBeenNthCalledWith( + 2, + 'https://docs-assets.developer.apple.com/design/example.png', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + }); + + it('should reject direct image examples redirected outside the Apple allowlist before fetching them', async () => { + (httpClient.get as jest.Mock).mockResolvedValue( + createRedirectResponse('https://example.com/example.png'), + ); + + await expect(handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/images/example.png', + limit: 1, + })).rejects.toThrow('outside the Apple Design allowlist'); + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); + + it('should reject direct image example responses whose final URL is outside the Apple allowlist', async () => { + const imageBytes = Buffer.from('preview-image'); + (httpClient.get as jest.Mock).mockResolvedValue( + createResponseWithUrl(imageBytes, 'image/png', 'https://example.com/example.png'), + ); + + await expect(handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/images/example.png', + limit: 1, + })).rejects.toThrow('outside the Apple Design allowlist'); + expect(httpClient.get).toHaveBeenCalledWith( + 'https://developer.apple.com/design/images/example.png', + expect.objectContaining({ + allowManualRedirect: true, + redirect: 'manual', + }), + ); + }); + + it('should reject oversized direct image examples before reading the body', async () => { + const arrayBuffer = jest.fn(); + const oversizedResponse = { + headers: new Headers({ + 'content-length': String(11 * 1024 * 1024), + 'content-type': 'image/png', + }), + body: null, + arrayBuffer, + } as unknown as Response; + (httpClient.get as jest.Mock).mockResolvedValue(oversizedResponse); + + await expect(handleGetAppleDesignExamples({ + url: 'https://developer.apple.com/design/images/huge.png', + limit: 1, + })).rejects.toThrow('Apple Design image preview exceeds'); + + expect(arrayBuffer).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/tools/handlers.test.ts b/tests/tools/handlers.test.ts index 696dd49..e057982 100644 --- a/tests/tools/handlers.test.ts +++ b/tests/tools/handlers.test.ts @@ -70,6 +70,36 @@ describe('Tool Handlers', () => { getSampleCode: jest.fn().mockResolvedValue({ content: [{ type: 'text', text: 'Sample code' }], }), + searchAppleDesignDocs: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Design search results' }], + }), + getAppleDesignContent: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Design content' }], + }), + listAppleDesignResources: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Design resources' }], + }), + downloadAppleDesignResource: jest.fn().mockResolvedValue({ + content: [ + { type: 'text', text: 'Downloaded resource' }, + { + type: 'resource_link', + uri: 'apple-design://cache/test/example.zip', + name: 'example.zip', + mimeType: 'application/zip', + }, + ], + }), + getAppleDesignExamples: jest.fn().mockResolvedValue({ + content: [ + { type: 'text', text: 'Design examples' }, + { + type: 'image', + data: Buffer.from('image').toString('base64'), + mimeType: 'image/png', + }, + ], + }), }; }); @@ -110,6 +140,130 @@ describe('Tool Handlers', () => { }); }); + it('should handle search_apple_design_docs tool', async () => { + const args = { query: 'layout', contentType: 'all', platform: 'iOS', limit: 5 }; + const result = await handleToolCall('search_apple_design_docs', args, mockServer); + + expect(mockServer.searchAppleDesignDocs).toHaveBeenCalledWith('layout', 'all', 'iOS', 5); + expect(result).toEqual({ + content: [{ type: 'text', text: 'Design search results' }], + }); + }); + + it('should handle get_apple_design_content tool', async () => { + const args = { url: 'https://developer.apple.com/design/human-interface-guidelines/layout' }; + const result = await handleToolCall('get_apple_design_content', args, mockServer); + + expect(mockServer.getAppleDesignContent).toHaveBeenCalledWith(args.url); + expect(result).toEqual({ + content: [{ type: 'text', text: 'Design content' }], + }); + }); + + it('should handle list_apple_design_resources tool', async () => { + const args = { + category: 'Design templates', + platform: 'iOS', + format: 'figma', + searchQuery: 'template', + limit: 10, + }; + const result = await handleToolCall('list_apple_design_resources', args, mockServer); + + expect(mockServer.listAppleDesignResources).toHaveBeenCalledWith( + 'Design templates', + 'iOS', + 'figma', + 'template', + 10, + ); + expect(result).toEqual({ + content: [{ type: 'text', text: 'Design resources' }], + }); + }); + + it('should handle list_apple_design_resources tool with omitted arguments', async () => { + const result = await handleToolCall('list_apple_design_resources', undefined, mockServer); + + expect(mockServer.listAppleDesignResources).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + undefined, + 50, + ); + expect(result).toEqual({ + content: [{ type: 'text', text: 'Design resources' }], + }); + }); + + it('should handle download_apple_design_resource tool with resource links', async () => { + const args = { resourceId: 'design-resource:templates:ios:download' }; + const result = await handleToolCall('download_apple_design_resource', args, mockServer); + + expect(mockServer.downloadAppleDesignResource).toHaveBeenCalledWith( + 'design-resource:templates:ios:download', + undefined, + undefined, + ); + expect(result.content).toEqual([ + { type: 'text', text: 'Downloaded resource' }, + { + type: 'resource_link', + uri: 'apple-design://cache/test/example.zip', + name: 'example.zip', + mimeType: 'application/zip', + }, + ]); + }); + + it('should handle download_apple_design_resource tool with omitted arguments', async () => { + await handleToolCall('download_apple_design_resource', undefined, mockServer); + + expect(mockServer.downloadAppleDesignResource).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + ); + }); + + it('should handle get_apple_design_examples tool with image blocks', async () => { + const args = { + url: 'https://developer.apple.com/design/human-interface-guidelines/layout', + limit: 1, + }; + const result = await handleToolCall('get_apple_design_examples', args, mockServer); + + expect(mockServer.getAppleDesignExamples).toHaveBeenCalledWith(args.url, undefined, undefined, 1); + expect(result.content).toEqual([ + { type: 'text', text: 'Design examples' }, + { + type: 'image', + data: Buffer.from('image').toString('base64'), + mimeType: 'image/png', + }, + ]); + }); + + it('should handle get_apple_design_examples tool with omitted arguments', async () => { + const result = await handleToolCall('get_apple_design_examples', undefined, mockServer); + + expect(mockServer.getAppleDesignExamples).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + 3, + ); + expect(result.content).toEqual([ + { type: 'text', text: 'Design examples' }, + { + type: 'image', + data: Buffer.from('image').toString('base64'), + mimeType: 'image/png', + }, + ]); + }); + it('should handle validation errors gracefully', async () => { // Mock schema parse to throw validation error jest.spyOn(schemas.searchAppleDocsSchema, 'parse').mockImplementationOnce(() => { @@ -136,6 +290,11 @@ describe('Tool Handlers', () => { 'get_documentation_updates', 'get_technology_overviews', 'get_sample_code', + 'search_apple_design_docs', + 'get_apple_design_content', + 'list_apple_design_resources', + 'download_apple_design_resource', + 'get_apple_design_examples', ]; expectedTools.forEach(tool => { @@ -206,4 +365,4 @@ describe('Tool Handlers', () => { ); }); }); -}); \ No newline at end of file +}); diff --git a/tests/utils/url-converter.test.ts b/tests/utils/url-converter.test.ts index 62edf76..45b0cbe 100644 --- a/tests/utils/url-converter.test.ts +++ b/tests/utils/url-converter.test.ts @@ -2,7 +2,13 @@ * Tests for URL converter utilities */ -import { convertToJsonApiUrl, isValidAppleDeveloperUrl, extractApiNameFromUrl } from '../../src/utils/url-converter.js'; +import { + convertToDesignJsonApiUrl, + convertToJsonApiUrl, + extractApiNameFromUrl, + isAppleDesignUrl, + isValidAppleDeveloperUrl, +} from '../../src/utils/url-converter.js'; describe('URL Converter', () => { describe('convertToJsonApiUrl', () => { @@ -32,6 +38,38 @@ describe('URL Converter', () => { expect(convertToJsonApiUrl(webUrl)).toBe(webUrl); }); + + it('should convert Human Interface Guidelines root URL to JSON API URL', () => { + const webUrl = 'https://developer.apple.com/design/human-interface-guidelines'; + const expected = 'https://developer.apple.com/tutorials/data/design/human-interface-guidelines.json'; + + expect(convertToJsonApiUrl(webUrl)).toBe(expected); + expect(convertToDesignJsonApiUrl(webUrl)).toBe(expected); + }); + + it('should convert Human Interface Guidelines child URL to JSON API URL', () => { + const webUrl = 'https://developer.apple.com/design/human-interface-guidelines/layout'; + const expected = 'https://developer.apple.com/tutorials/data/design/human-interface-guidelines/layout.json'; + + expect(convertToJsonApiUrl(webUrl)).toBe(expected); + expect(convertToDesignJsonApiUrl(webUrl)).toBe(expected); + }); + + it('should convert Human Interface Guidelines URL with trailing slash', () => { + const webUrl = 'https://developer.apple.com/design/human-interface-guidelines/layout/'; + const expected = 'https://developer.apple.com/tutorials/data/design/human-interface-guidelines/layout.json'; + + expect(convertToJsonApiUrl(webUrl)).toBe(expected); + expect(convertToDesignJsonApiUrl(webUrl)).toBe(expected); + }); + + it('should convert Human Interface Guidelines URL with anchor', () => { + const webUrl = 'https://developer.apple.com/design/human-interface-guidelines/layout#columns'; + const expected = 'https://developer.apple.com/tutorials/data/design/human-interface-guidelines/layout.json'; + + expect(convertToJsonApiUrl(webUrl)).toBe(expected); + expect(convertToDesignJsonApiUrl(webUrl)).toBe(expected); + }); }); describe('isValidAppleDeveloperUrl', () => { @@ -61,6 +99,34 @@ describe('URL Converter', () => { }); }); + describe('isAppleDesignUrl', () => { + it('should return true for Apple Design URLs', () => { + const validUrls = [ + 'https://developer.apple.com/design', + 'https://developer.apple.com/design/', + 'https://developer.apple.com/design/resources/', + 'https://developer.apple.com/design/human-interface-guidelines/layout', + ]; + + validUrls.forEach(url => { + expect(isAppleDesignUrl(url)).toBe(true); + }); + }); + + it('should return false for non-design URLs', () => { + const invalidUrls = [ + 'https://developer.apple.com/documentation/swiftui', + 'https://developer.apple.com/news/', + 'https://apple.com/design/', + 'not-a-url', + ]; + + invalidUrls.forEach(url => { + expect(isAppleDesignUrl(url)).toBe(false); + }); + }); + }); + describe('extractApiNameFromUrl', () => { it('should extract API name from URL', () => { const testCases = [ @@ -92,4 +158,4 @@ describe('URL Converter', () => { expect(extractApiNameFromUrl('not-a-url')).toBe('Unknown API'); }); }); -}); \ No newline at end of file +});