@nshipster/sosumi 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,739 @@
1
+ /**
2
+ * Apple Developer Reference documentation rendering functionality
3
+ */
4
+
5
+ import type {
6
+ AppleDocJSON,
7
+ ContentItem,
8
+ IndexContentItem,
9
+ PropertyItem,
10
+ TopicSection,
11
+ Variant,
12
+ } from "./types"
13
+
14
+ interface RenderOptions {
15
+ externalOrigin?: string
16
+ }
17
+
18
+ /**
19
+ * Render JSON-based extracted content to markdown
20
+ */
21
+ export async function renderFromJSON(
22
+ jsonData: AppleDocJSON,
23
+ sourceUrl: string,
24
+ options: RenderOptions = {},
25
+ ): Promise<string> {
26
+ let markdown = ""
27
+
28
+ // Generate front matter
29
+ markdown += generateFrontMatterFromJSON(jsonData, sourceUrl)
30
+
31
+ // Add navigation breadcrumbs
32
+ const breadcrumbs = generateBreadcrumbs(sourceUrl, options.externalOrigin)
33
+ if (breadcrumbs) {
34
+ markdown += breadcrumbs
35
+ }
36
+
37
+ // Add symbol type and name
38
+ if (jsonData.metadata?.roleHeading) {
39
+ markdown += `**${jsonData.metadata.roleHeading}**\n\n`
40
+ }
41
+
42
+ // Add title
43
+ const title = jsonData.metadata?.title || ""
44
+ if (title) {
45
+ markdown += `# ${title}\n\n`
46
+ }
47
+
48
+ // Add platform availability
49
+ if (jsonData.metadata?.platforms && jsonData.metadata.platforms.length > 0) {
50
+ const platforms = jsonData.metadata.platforms
51
+ .map((p) => `${p.name} ${p.introducedAt}+${p.beta ? " Beta" : ""}`)
52
+ .join(", ")
53
+ markdown += `**Available on:** ${platforms}\n\n`
54
+ }
55
+
56
+ // Add abstract
57
+ if (jsonData.abstract && Array.isArray(jsonData.abstract)) {
58
+ const abstractText = jsonData.abstract
59
+ .filter((item) => item.type === "text")
60
+ .map((item) => item.text)
61
+ .join("")
62
+
63
+ if (abstractText.trim()) {
64
+ markdown += `> ${abstractText}\n\n`
65
+ }
66
+ }
67
+
68
+ // Add declaration
69
+ if (jsonData.primaryContentSections) {
70
+ const declarationSection = jsonData.primaryContentSections.find(
71
+ (s) => s.kind === "declarations",
72
+ )
73
+ if (declarationSection?.declarations) {
74
+ markdown += renderDeclarations(declarationSection.declarations)
75
+ }
76
+
77
+ // Add parameters
78
+ const parametersSection = jsonData.primaryContentSections.find((s) => s.kind === "parameters")
79
+ if (parametersSection?.parameters) {
80
+ markdown += renderParameters(
81
+ parametersSection.parameters,
82
+ jsonData.references,
83
+ options.externalOrigin,
84
+ )
85
+ }
86
+
87
+ // Add properties (used by object/dictionary pages in data docs)
88
+ const propertiesSection = jsonData.primaryContentSections.find((s) => s.kind === "properties")
89
+ if (propertiesSection?.items) {
90
+ markdown += renderProperties(
91
+ propertiesSection.items,
92
+ jsonData.references,
93
+ options.externalOrigin,
94
+ )
95
+ }
96
+
97
+ // Add content sections
98
+ const contentSections = jsonData.primaryContentSections.filter((s) => s.kind === "content")
99
+ for (const section of contentSections) {
100
+ if (section.content) {
101
+ markdown += renderContent(section.content, jsonData.references, options.externalOrigin)
102
+ }
103
+ }
104
+ }
105
+
106
+ // Add relationship sections (Inherited By, Conforming Types, etc.)
107
+ if (jsonData.relationshipsSections) {
108
+ markdown += renderRelationships(
109
+ jsonData.relationshipsSections,
110
+ jsonData.variants,
111
+ jsonData.references,
112
+ options.externalOrigin,
113
+ )
114
+ }
115
+
116
+ // Add topic sections
117
+ if (jsonData.topicSections) {
118
+ markdown += renderTopicSections(
119
+ jsonData.topicSections,
120
+ jsonData.variants,
121
+ jsonData.references,
122
+ options.externalOrigin,
123
+ )
124
+ }
125
+
126
+ // Add index content for framework pages
127
+ if (jsonData.interfaceLanguages?.swift) {
128
+ const swiftContent = jsonData.interfaceLanguages.swift[0]
129
+ if (swiftContent.children) {
130
+ markdown += renderIndexContent(swiftContent.children, options.externalOrigin)
131
+ }
132
+ }
133
+
134
+ // Add see also sections
135
+ if (jsonData.seeAlsoSections) {
136
+ markdown += renderSeeAlso(
137
+ jsonData.seeAlsoSections,
138
+ jsonData.variants,
139
+ jsonData.references,
140
+ options.externalOrigin,
141
+ )
142
+ }
143
+
144
+ // Trim whitespace
145
+ markdown = markdown.trim()
146
+
147
+ // Add footer
148
+ markdown += `\n\n---\n\n`
149
+ markdown += `*Extracted by [sosumi.ai](https://sosumi.ai) - Making Apple docs AI-readable.*\n`
150
+ markdown += `*This is unofficial content. All documentation belongs to Apple Inc.*\n`
151
+
152
+ return markdown
153
+ }
154
+
155
+ /**
156
+ * Generate YAML front-matter from JSON data
157
+ */
158
+ function generateFrontMatterFromJSON(jsonData: AppleDocJSON, sourceUrl: string): string {
159
+ const frontMatter: Record<string, string> = {}
160
+
161
+ if (jsonData.metadata?.title) {
162
+ const cleanTitle = jsonData.metadata.title.replace("| Apple Developer Documentation", "").trim()
163
+ frontMatter.title = cleanTitle
164
+ } else if (jsonData.interfaceLanguages?.swift?.[0]?.title) {
165
+ frontMatter.title = jsonData.interfaceLanguages.swift[0].title
166
+ }
167
+
168
+ if (jsonData.abstract && Array.isArray(jsonData.abstract)) {
169
+ const description = jsonData.abstract
170
+ .filter((item) => item.type === "text")
171
+ .map((item) => item.text)
172
+ .join("")
173
+ .trim()
174
+ if (description) {
175
+ frontMatter.description = description
176
+ }
177
+ }
178
+
179
+ frontMatter.source = sourceUrl
180
+ frontMatter.timestamp = new Date().toISOString()
181
+
182
+ // Convert to YAML format
183
+ const yamlLines = Object.entries(frontMatter).map(([key, value]) => `${key}: ${value}`)
184
+ return `---\n${yamlLines.join("\n")}\n---\n\n`
185
+ }
186
+
187
+ /**
188
+ * Generate breadcrumb navigation
189
+ */
190
+ function generateBreadcrumbs(sourceUrl: string, externalOrigin?: string): string {
191
+ const url = new URL(sourceUrl)
192
+ const pathParts = url.pathname.split("/").filter(Boolean)
193
+ const documentationIndex = pathParts.indexOf("documentation")
194
+
195
+ if (documentationIndex === -1 || pathParts.length <= documentationIndex + 2) {
196
+ return ""
197
+ }
198
+
199
+ const framework = pathParts[documentationIndex + 1]
200
+ let breadcrumbs = `**Navigation:** [${framework.charAt(0).toUpperCase() + framework.slice(1)}](${rewriteDocumentationPath(
201
+ `/documentation/${framework}`,
202
+ externalOrigin,
203
+ )})`
204
+
205
+ if (pathParts.length > documentationIndex + 2) {
206
+ // Add intermediate breadcrumbs if needed
207
+ for (let i = documentationIndex + 2; i < pathParts.length - 1; i++) {
208
+ const part = pathParts[i]
209
+ const path = pathParts.slice(documentationIndex, i + 1).join("/")
210
+ breadcrumbs += ` › [${part}](${rewriteDocumentationPath(`/${path}`, externalOrigin)})`
211
+ }
212
+ }
213
+
214
+ return `${breadcrumbs}\n\n`
215
+ }
216
+
217
+ /**
218
+ * Render declaration sections
219
+ */
220
+ function renderDeclarations(declarations: Array<{ tokens?: Array<{ text?: string }> }>): string {
221
+ let markdown = ""
222
+
223
+ for (const decl of declarations) {
224
+ if (decl.tokens) {
225
+ // Simply concatenate the tokens as Apple has them formatted
226
+ const code = decl.tokens
227
+ .filter((token) => token != null)
228
+ .map((token) => token.text || "")
229
+ .join("")
230
+ .trim()
231
+
232
+ markdown += `\`\`\`swift\n${code}\n\`\`\`\n\n`
233
+ }
234
+ }
235
+
236
+ return markdown
237
+ }
238
+
239
+ /**
240
+ * Render parameters section
241
+ */
242
+ function renderParameters(
243
+ parameters: Array<{ name: string; content?: ContentItem[] }>,
244
+ references?: Record<string, ContentItem>,
245
+ externalOrigin?: string,
246
+ ): string {
247
+ if (parameters.length === 0) return ""
248
+
249
+ let markdown = "## Parameters\n\n"
250
+
251
+ for (const param of parameters) {
252
+ markdown += `**${param.name}**\n\n`
253
+ if (param.content && Array.isArray(param.content)) {
254
+ const paramText = renderContentArray(param.content, references, 0, externalOrigin)
255
+ markdown += `${paramText}\n\n`
256
+ }
257
+ }
258
+
259
+ return markdown
260
+ }
261
+
262
+ /**
263
+ * Render properties section for data dictionary pages.
264
+ */
265
+ function renderProperties(
266
+ properties: PropertyItem[],
267
+ references?: Record<string, ContentItem>,
268
+ externalOrigin?: string,
269
+ ): string {
270
+ if (properties.length === 0) return ""
271
+
272
+ let markdown = "## Properties\n\n"
273
+
274
+ for (const property of properties) {
275
+ if (!property.name) continue
276
+
277
+ const typeText = renderPropertyType(property.type, references, externalOrigin)
278
+ const requiredText = property.required === true ? "required" : "optional"
279
+ const metadata = [typeText, requiredText].filter(Boolean)
280
+ const headingSuffix = metadata.length > 0 ? ` *(${metadata.join(", ")})*` : ""
281
+ markdown += `### \`${property.name}\`${headingSuffix}\n\n`
282
+
283
+ if (property.content && Array.isArray(property.content)) {
284
+ markdown += `${renderContentArray(property.content, references, 0, externalOrigin)}`
285
+ }
286
+
287
+ const allowedValues = property.attributes?.find((a) => a.kind === "allowedValues")?.values
288
+ if (allowedValues && allowedValues.length > 0) {
289
+ const possibleValues = allowedValues.map((value) => `\`${value}\``).join(", ")
290
+ markdown += `Possible Values: ${possibleValues}\n\n`
291
+ }
292
+ }
293
+
294
+ return markdown
295
+ }
296
+
297
+ function renderPropertyType(
298
+ type: Array<{ text?: string; kind?: string; identifier?: string }> | undefined,
299
+ references?: Record<string, ContentItem>,
300
+ externalOrigin?: string,
301
+ ): string {
302
+ if (!type || type.length === 0) return ""
303
+
304
+ return type
305
+ .map((part) => {
306
+ if (part.kind === "typeIdentifier" && part.identifier && part.text) {
307
+ const url = convertIdentifierToURL(part.identifier, references, externalOrigin)
308
+ return url ? `[${part.text}](${url})` : part.text
309
+ }
310
+ return part.text || ""
311
+ })
312
+ .join("")
313
+ .trim()
314
+ }
315
+
316
+ /**
317
+ * Render main content sections
318
+ */
319
+ function renderContent(
320
+ content: ContentItem[],
321
+ references?: Record<string, ContentItem>,
322
+ externalOrigin?: string,
323
+ ): string {
324
+ return renderContentArray(content, references, 0, externalOrigin)
325
+ }
326
+
327
+ /**
328
+ * Render content array to markdown
329
+ */
330
+ function renderContentArray(
331
+ content: ContentItem[],
332
+ references?: Record<string, ContentItem>,
333
+ depth: number = 0,
334
+ externalOrigin?: string,
335
+ ): string {
336
+ // Prevent infinite recursion by limiting depth
337
+ if (depth > 50) {
338
+ console.warn("Maximum recursion depth reached in renderContentArray")
339
+ return "[Content too deeply nested]"
340
+ }
341
+
342
+ let markdown = ""
343
+
344
+ for (const item of content) {
345
+ if (item.type === "heading") {
346
+ const level = Math.min(item.level || 2, 6)
347
+ const hashes = "#".repeat(level)
348
+ markdown += `${hashes} ${item.text}\n\n`
349
+ } else if (item.type === "paragraph") {
350
+ if (item.inlineContent) {
351
+ const text = renderInlineContent(item.inlineContent, references, depth, externalOrigin)
352
+ markdown += `${text}\n\n`
353
+ }
354
+ } else if (item.type === "codeListing") {
355
+ let code = ""
356
+ if (Array.isArray(item.code)) {
357
+ code = item.code.join("\n")
358
+ } else {
359
+ code = String(item.code || "")
360
+ }
361
+ const syntax = item.syntax || "swift"
362
+
363
+ markdown += `\`\`\`${syntax}\n${code}\n\`\`\`\n\n`
364
+ } else if (item.type === "unorderedList") {
365
+ if (item.items) {
366
+ for (const listItem of item.items) {
367
+ const itemText = renderContentArray(
368
+ listItem.content || [],
369
+ references,
370
+ depth + 1,
371
+ externalOrigin,
372
+ )
373
+ markdown += `- ${itemText.replace(/\n\n$/, "")}\n`
374
+ }
375
+ markdown += "\n"
376
+ }
377
+ } else if (item.type === "orderedList") {
378
+ if (item.items) {
379
+ item.items.forEach((listItem: ContentItem, index: number) => {
380
+ const itemText = renderContentArray(
381
+ listItem.content || [],
382
+ references,
383
+ depth + 1,
384
+ externalOrigin,
385
+ )
386
+ markdown += `${index + 1}. ${itemText.replace(/\n\n$/, "")}\n`
387
+ })
388
+ markdown += "\n"
389
+ }
390
+ } else if (item.type === "aside") {
391
+ const style = item.style || "note"
392
+ const calloutType = mapAsideStyleToCallout(style)
393
+ const asideContent = item.content
394
+ ? renderContentArray(item.content, references, depth + 1, externalOrigin)
395
+ : ""
396
+ const cleanContent = asideContent.trim().replace(/\n/g, "\n> ")
397
+ markdown += `> [!${calloutType}]\n> ${cleanContent}\n\n`
398
+ } else if (item.type === "table") {
399
+ markdown += renderTable(item, references, depth, externalOrigin)
400
+ }
401
+ }
402
+
403
+ return markdown
404
+ }
405
+
406
+ /**
407
+ * Render a table content item to markdown.
408
+ * Apple's JSON uses header: "row" (first row is header) and rows where each cell is ContentItem[].
409
+ */
410
+ function renderTable(
411
+ item: ContentItem,
412
+ references?: Record<string, ContentItem>,
413
+ depth: number = 0,
414
+ externalOrigin?: string,
415
+ ): string {
416
+ const table = item as ContentItem & {
417
+ header?: string
418
+ rows?: ContentItem[][][] // rows[rowIndex][cellIndex] = ContentItem[]
419
+ }
420
+ const rows = table.rows ?? []
421
+ if (rows.length === 0) return ""
422
+
423
+ const escapeCell = (s: string) => s.replace(/\|/g, "\\|").replace(/\n/g, " ").trim()
424
+ const renderCell = (cell: ContentItem | ContentItem[]) => {
425
+ const items = Array.isArray(cell) ? cell : [cell]
426
+ const s = renderContentArray(items, references, depth + 1, externalOrigin)
427
+ return escapeCell(s)
428
+ }
429
+
430
+ const firstRowIsHeader = table.header === "row"
431
+ let markdown = ""
432
+ rows.forEach((row, rowIndex) => {
433
+ const cells = row.map((c) => renderCell(c))
434
+ if (cells.length === 0) return
435
+ markdown += `| ${cells.join(" | ")} |\n`
436
+ if (firstRowIsHeader && rowIndex === 0) {
437
+ markdown += `| ${cells.map(() => "---").join(" | ")} |\n`
438
+ }
439
+ })
440
+ return markdown ? `${markdown}\n` : ""
441
+ }
442
+
443
+ /**
444
+ * Render inline content to markdown
445
+ */
446
+ function renderInlineContent(
447
+ inlineContent: ContentItem[],
448
+ references?: Record<string, ContentItem>,
449
+ depth: number = 0,
450
+ externalOrigin?: string,
451
+ ): string {
452
+ // Prevent infinite recursion by limiting depth
453
+ if (depth > 20) {
454
+ console.warn("Maximum recursion depth reached in renderInlineContent")
455
+ return "[Inline content too deeply nested]"
456
+ }
457
+
458
+ return inlineContent
459
+ .map((item) => {
460
+ if (item.type === "text") {
461
+ return item.text
462
+ } else if (item.type === "codeVoice") {
463
+ return `\`${item.code}\``
464
+ } else if (item.type === "reference") {
465
+ const title =
466
+ item.title ||
467
+ item.text ||
468
+ (item.identifier ? extractTitleFromIdentifier(item.identifier) : "")
469
+ const url = item.identifier
470
+ ? convertIdentifierToURL(item.identifier, references, externalOrigin)
471
+ : ""
472
+ return `[${title}](${url})`
473
+ } else if (item.type === "emphasis") {
474
+ return `*${item.inlineContent ? renderInlineContent(item.inlineContent, references, depth + 1, externalOrigin) : ""}*`
475
+ } else if (item.type === "strong") {
476
+ return `**${item.inlineContent ? renderInlineContent(item.inlineContent, references, depth + 1, externalOrigin) : ""}**`
477
+ } else if (item.type === "image" && item.identifier) {
478
+ const ref = references?.[item.identifier] as
479
+ | (ContentItem & { variants?: Array<{ url: string }>; alt?: string })
480
+ | undefined
481
+ const url = ref?.variants?.[0]?.url
482
+ const alt = ref?.alt ?? ""
483
+ return url ? `![${alt}](${url})` : ""
484
+ }
485
+ return item.text || ""
486
+ })
487
+ .join("")
488
+ }
489
+
490
+ /**
491
+ * Render relationship sections
492
+ */
493
+ function renderRelationships(
494
+ relationships: ContentItem[],
495
+ variants?: Variant[],
496
+ references?: Record<string, ContentItem>,
497
+ externalOrigin?: string,
498
+ ): string {
499
+ let markdown = ""
500
+
501
+ for (const rel of relationships) {
502
+ if (rel.title && rel.identifiers) {
503
+ markdown += `## ${rel.title}\n\n`
504
+ for (const id of rel.identifiers) {
505
+ const info = variants?.find((v: Variant) => v.identifier === id)
506
+ const reference = references?.[id]
507
+ const title = info?.title || reference?.title || extractTitleFromIdentifier(id)
508
+ const url = convertIdentifierToURL(id, references, externalOrigin)
509
+ markdown += `- [${title}](${url})\n`
510
+ }
511
+ markdown += "\n"
512
+ }
513
+ }
514
+
515
+ return markdown
516
+ }
517
+
518
+ /**
519
+ * Render topic sections
520
+ */
521
+ function renderTopicSections(
522
+ topics: TopicSection[],
523
+ variants?: Variant[],
524
+ references?: Record<string, ContentItem>,
525
+ externalOrigin?: string,
526
+ ): string {
527
+ let markdown = ""
528
+
529
+ for (const topic of topics) {
530
+ if (topic.title) {
531
+ markdown += `## ${topic.title}\n\n`
532
+
533
+ if (topic.identifiers) {
534
+ for (const id of topic.identifiers) {
535
+ const info = variants?.find((v: Variant) => v.identifier === id)
536
+ const reference = references?.[id]
537
+ if (info || reference) {
538
+ const title = info?.title || reference?.title || extractTitleFromIdentifier(id)
539
+ const url = convertIdentifierToURL(id, references, externalOrigin)
540
+ const abstract = info?.abstract
541
+ ? info.abstract.map((a: { text: string }) => a.text).join("")
542
+ : reference?.abstract
543
+ ? reference.abstract.map((a: { text: string }) => a.text).join("")
544
+ : ""
545
+
546
+ markdown += `- [${title}](${url})`
547
+ if (abstract) {
548
+ markdown += ` ${abstract}`
549
+ }
550
+ markdown += "\n"
551
+ } else {
552
+ const title = extractTitleFromIdentifier(id)
553
+ const url = convertIdentifierToURL(id, references, externalOrigin)
554
+ markdown += `- [${title}](${url})\n`
555
+ }
556
+ }
557
+ markdown += "\n"
558
+ }
559
+ }
560
+ }
561
+
562
+ return markdown
563
+ }
564
+
565
+ /**
566
+ * Render index content for framework pages
567
+ */
568
+ function renderIndexContent(children: IndexContentItem[], externalOrigin?: string): string {
569
+ return renderIndexContentWithIndent(children, 2, externalOrigin)
570
+ }
571
+
572
+ /**
573
+ * Render index content with proper indentation and spacing
574
+ */
575
+ function renderIndexContentWithIndent(
576
+ children: IndexContentItem[],
577
+ headingLevel: number,
578
+ externalOrigin?: string,
579
+ ): string {
580
+ let markdown = ""
581
+
582
+ for (let i = 0; i < children.length; i++) {
583
+ const child = children[i]
584
+
585
+ if (child.type === "groupMarker") {
586
+ // Add spacing before group markers (except the first one)
587
+ if (i > 0) {
588
+ markdown += "\n"
589
+ }
590
+
591
+ // Group markers are headings at the current level
592
+ const hashes = "#".repeat(Math.min(headingLevel, 6))
593
+ markdown += `${hashes} ${child.title}\n\n`
594
+ } else if (child.path && child.title) {
595
+ const beta = child.beta ? " **Beta**" : ""
596
+
597
+ // List items are always unindented under their heading
598
+ const rewrittenPath = rewriteDocumentationPath(child.path, externalOrigin)
599
+ markdown += `- [${child.title}](${rewrittenPath})${beta}\n`
600
+
601
+ if (child.children) {
602
+ // Add spacing before nested content
603
+ markdown += "\n"
604
+ // Nested content gets a deeper heading level
605
+ const nestedContent = renderIndexContentWithIndent(
606
+ child.children,
607
+ headingLevel + 1,
608
+ externalOrigin,
609
+ )
610
+ markdown += nestedContent
611
+ }
612
+ }
613
+ }
614
+
615
+ return markdown
616
+ }
617
+
618
+ /**
619
+ * Render see also sections
620
+ */
621
+ function renderSeeAlso(
622
+ seeAlso: Array<{ title: string; identifiers?: string[] }>,
623
+ variants?: Variant[],
624
+ references?: Record<string, ContentItem>,
625
+ externalOrigin?: string,
626
+ ): string {
627
+ let markdown = ""
628
+
629
+ for (const section of seeAlso) {
630
+ if (section.title && section.identifiers) {
631
+ markdown += `## ${section.title}\n\n`
632
+ for (const id of section.identifiers) {
633
+ const info = variants?.find((v: Variant) => v.identifier === id)
634
+ const reference = references?.[id]
635
+ const title = info?.title || reference?.title || extractTitleFromIdentifier(id)
636
+ const url = convertIdentifierToURL(id, references, externalOrigin)
637
+ markdown += `- [${title}](${url})\n`
638
+ }
639
+ markdown += "\n"
640
+ }
641
+ }
642
+
643
+ return markdown
644
+ }
645
+
646
+ /**
647
+ * Map aside style to GitHub-style callout
648
+ */
649
+ function mapAsideStyleToCallout(style: string): string {
650
+ switch (style.toLowerCase()) {
651
+ case "warning":
652
+ return "WARNING"
653
+ case "important":
654
+ return "IMPORTANT"
655
+ case "caution":
656
+ return "CAUTION"
657
+ case "tip":
658
+ return "TIP"
659
+ case "deprecated":
660
+ return "WARNING"
661
+ default:
662
+ return "NOTE"
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Convert doc:// identifier to sosumi.ai URL
668
+ */
669
+ function convertIdentifierToURL(
670
+ identifier: string,
671
+ references?: Record<string, ContentItem>,
672
+ externalOrigin?: string,
673
+ ): string {
674
+ // Check if we have a reference with a URL for this identifier
675
+ const reference = references?.[identifier]
676
+ if (reference?.url) {
677
+ return rewriteDocumentationPath(reference.url, externalOrigin)
678
+ }
679
+
680
+ if (identifier.startsWith("doc://com.apple.SwiftUI/documentation/")) {
681
+ const path = identifier.replace("doc://com.apple.SwiftUI/documentation/", "/documentation/")
682
+ return rewriteDocumentationPath(path, externalOrigin)
683
+ } else if (identifier.startsWith("doc://com.apple.")) {
684
+ // Handle other Apple docs
685
+ const matches = identifier.match(/\/documentation\/(.+)/)
686
+ if (matches) {
687
+ return rewriteDocumentationPath(`/documentation/${matches[1]}`, externalOrigin)
688
+ }
689
+ } else if (identifier.startsWith("doc://")) {
690
+ const matches = identifier.match(/\/documentation\/(.+)/)
691
+ if (matches) {
692
+ return rewriteDocumentationPath(`/documentation/${matches[1]}`, externalOrigin)
693
+ }
694
+ }
695
+ return identifier
696
+ }
697
+
698
+ function rewriteDocumentationPath(path: string | undefined, externalOrigin?: string): string {
699
+ if (!path) {
700
+ return ""
701
+ }
702
+
703
+ if (!externalOrigin || !path.startsWith("/documentation/")) {
704
+ return path
705
+ }
706
+
707
+ return `/external/${externalOrigin}${path}`
708
+ }
709
+
710
+ /**
711
+ * Extract title from identifier
712
+ */
713
+ export function extractTitleFromIdentifier(identifier: string): string {
714
+ const parts = identifier.split("/")
715
+ const lastPart = parts[parts.length - 1]
716
+
717
+ // Handle disambiguation suffixes (e.g., "body-8kl5o" -> "body", "init(exactly:)-63925" -> "init(exactly:)")
718
+ const disambiguationMatch = lastPart.match(/^(.+?)(?:-\w+)?$/)
719
+ if (disambiguationMatch) {
720
+ const baseName = disambiguationMatch[1]
721
+
722
+ // If it looks like a method signature (contains parentheses), preserve it
723
+ if (baseName.includes("(") && baseName.includes(")")) {
724
+ return baseName
725
+ }
726
+
727
+ // For simple identifiers, convert camelCase to readable format
728
+ return baseName
729
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
730
+ .replace(/\s+/g, " ")
731
+ .trim()
732
+ }
733
+
734
+ // Fallback: convert camelCase to readable format
735
+ return lastPart
736
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
737
+ .replace(/\s+/g, " ")
738
+ .trim()
739
+ }