@nshipster/sosumi 1.0.6 → 1.0.8

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.
@@ -2,6 +2,18 @@
2
2
  * Apple Developer Reference documentation rendering functionality
3
3
  */
4
4
 
5
+ import {
6
+ CONTENT_TOO_DEEP,
7
+ formatCallout,
8
+ formatCodeBlock,
9
+ formatList,
10
+ formatTable,
11
+ INLINE_CONTENT_TOO_DEEP,
12
+ MAX_CONTENT_DEPTH,
13
+ MAX_INLINE_DEPTH,
14
+ mapAsideStyleToCallout,
15
+ } from "../markdown"
16
+ import { extractTitleFromIdentifier } from "../url"
5
17
  import type {
6
18
  AppleDocJSON,
7
19
  ContentItem,
@@ -235,14 +247,6 @@ function generateBreadcrumbs(sourceUrl: string, externalOrigin?: string): string
235
247
  return `${breadcrumbs}\n\n`
236
248
  }
237
249
 
238
- /**
239
- * Map a DocC language code to a Markdown code-fence identifier. DocC uses
240
- * `occ` for Objective-C internally, but Markdown highlighters expect `objc`.
241
- */
242
- function normalizeFenceLanguage(syntax: string): string {
243
- return syntax === "occ" ? "objc" : syntax
244
- }
245
-
246
250
  /**
247
251
  * Tab titles DocC uses for language-variant code examples. Used to detect
248
252
  * whether a `tabNavigator` is a language switch (so we can default to Swift)
@@ -399,9 +403,9 @@ function renderContentArray(
399
403
  externalOrigin?: string,
400
404
  ): string {
401
405
  // Prevent infinite recursion by limiting depth
402
- if (depth > 50) {
406
+ if (depth > MAX_CONTENT_DEPTH) {
403
407
  console.warn("Maximum recursion depth reached in renderContentArray")
404
- return "[Content too deeply nested]"
408
+ return CONTENT_TOO_DEEP
405
409
  }
406
410
 
407
411
  let markdown = ""
@@ -413,54 +417,29 @@ function renderContentArray(
413
417
  markdown += `${hashes} ${item.text}\n\n`
414
418
  } else if (item.type === "paragraph") {
415
419
  if (item.inlineContent) {
416
- const text = renderInlineContent(item.inlineContent, references, depth, externalOrigin)
420
+ // Inline nesting is counted separately from block nesting, so a
421
+ // paragraph's inline content starts a fresh depth budget.
422
+ const text = renderInlineContent(item.inlineContent, references, 0, externalOrigin)
417
423
  markdown += `${text}\n\n`
418
424
  }
419
425
  } else if (item.type === "codeListing") {
420
- let code = ""
421
- if (Array.isArray(item.code)) {
422
- code = item.code.join("\n")
423
- } else {
424
- code = String(item.code || "")
425
- }
426
- const syntax = normalizeFenceLanguage(item.syntax || "swift")
427
-
428
- markdown += `\`\`\`${syntax}\n${code}\n\`\`\`\n\n`
429
- } else if (item.type === "unorderedList") {
426
+ markdown += formatCodeBlock(item.code, item.syntax)
427
+ } else if (item.type === "unorderedList" || item.type === "orderedList") {
430
428
  if (item.items) {
431
- for (const listItem of item.items) {
432
- const itemText = renderContentArray(
433
- listItem.content || [],
434
- references,
435
- depth + 1,
436
- externalOrigin,
437
- )
438
- markdown += `- ${itemText.replace(/\n\n$/, "")}\n`
439
- }
440
- markdown += "\n"
441
- }
442
- } else if (item.type === "orderedList") {
443
- if (item.items) {
444
- item.items.forEach((listItem: ContentItem, index: number) => {
445
- const itemText = renderContentArray(
446
- listItem.content || [],
447
- references,
448
- depth + 1,
449
- externalOrigin,
450
- )
451
- markdown += `${index + 1}. ${itemText.replace(/\n\n$/, "")}\n`
452
- })
453
- markdown += "\n"
429
+ markdown += formatList(
430
+ item.items.map((listItem) =>
431
+ renderContentArray(listItem.content || [], references, depth + 1, externalOrigin),
432
+ ),
433
+ item.type === "orderedList",
434
+ )
454
435
  }
455
436
  } else if (item.type === "aside") {
456
437
  const style = item.style || "note"
457
- const calloutType = mapAsideStyleToCallout(style)
458
438
  const asideContent = item.content
459
439
  ? renderContentArray(item.content, references, depth + 1, externalOrigin)
460
440
  : ""
461
- const cleanContent = asideContent.trim().replace(/\n/g, "\n> ")
462
- const deprecatedLabel = style.toLowerCase() === "deprecated" ? "**Deprecated**\n>\n> " : ""
463
- markdown += `> [!${calloutType}]\n> ${deprecatedLabel}${cleanContent}\n\n`
441
+ const lead = style.toLowerCase() === "deprecated" ? DEPRECATED_LEAD : undefined
442
+ markdown += formatCallout(mapAsideStyleToCallout(style), asideContent, lead)
464
443
  } else if (item.type === "table") {
465
444
  markdown += renderTable(item, references, depth, externalOrigin)
466
445
  } else if (item.type === "tabNavigator" && item.tabs?.length) {
@@ -507,27 +486,17 @@ function renderTable(
507
486
  header?: string
508
487
  rows?: ContentItem[][][] // rows[rowIndex][cellIndex] = ContentItem[]
509
488
  }
510
- const rows = table.rows ?? []
511
- if (rows.length === 0) return ""
512
-
513
- const escapeCell = (s: string) => s.replace(/\|/g, "\\|").replace(/\n/g, " ").trim()
514
- const renderCell = (cell: ContentItem | ContentItem[]) => {
515
- const items = Array.isArray(cell) ? cell : [cell]
516
- const s = renderContentArray(items, references, depth + 1, externalOrigin)
517
- return escapeCell(s)
518
- }
519
-
520
- const firstRowIsHeader = table.header === "row"
521
- let markdown = ""
522
- rows.forEach((row, rowIndex) => {
523
- const cells = row.map((c) => renderCell(c))
524
- if (cells.length === 0) return
525
- markdown += `| ${cells.join(" | ")} |\n`
526
- if (firstRowIsHeader && rowIndex === 0) {
527
- markdown += `| ${cells.map(() => "---").join(" | ")} |\n`
528
- }
529
- })
530
- return markdown ? `${markdown}\n` : ""
489
+ const rows = (table.rows ?? []).map((row) =>
490
+ row.map((cell) =>
491
+ renderContentArray(
492
+ Array.isArray(cell) ? cell : [cell],
493
+ references,
494
+ depth + 1,
495
+ externalOrigin,
496
+ ),
497
+ ),
498
+ )
499
+ return formatTable(rows, table.header === "row")
531
500
  }
532
501
 
533
502
  /**
@@ -540,9 +509,9 @@ function renderInlineContent(
540
509
  externalOrigin?: string,
541
510
  ): string {
542
511
  // Prevent infinite recursion by limiting depth
543
- if (depth > 20) {
512
+ if (depth > MAX_INLINE_DEPTH) {
544
513
  console.warn("Maximum recursion depth reached in renderInlineContent")
545
- return "[Inline content too deeply nested]"
514
+ return INLINE_CONTENT_TOO_DEEP
546
515
  }
547
516
 
548
517
  return inlineContent
@@ -552,9 +521,11 @@ function renderInlineContent(
552
521
  } else if (item.type === "codeVoice") {
553
522
  return `\`${item.code}\``
554
523
  } else if (item.type === "reference") {
524
+ const reference = item.identifier ? references?.[item.identifier] : undefined
555
525
  const title =
556
526
  item.title ||
557
527
  item.text ||
528
+ resolveReferenceTitle(reference, references, depth, externalOrigin) ||
558
529
  (item.identifier ? extractTitleFromIdentifier(item.identifier) : "")
559
530
  const url = item.identifier
560
531
  ? convertIdentifierToURL(item.identifier, references, externalOrigin)
@@ -577,6 +548,56 @@ function renderInlineContent(
577
548
  .join("")
578
549
  }
579
550
 
551
+ /**
552
+ * Resolve the display title for a reference, preferring its rich
553
+ * `titleInlineContent` (which preserves code spans) over the flat `title`.
554
+ * When there is no rich title, wrap symbol titles in backticks so older DocC
555
+ * pages that omit `titleInlineContent` still render code spans in link text.
556
+ */
557
+ function resolveReferenceTitle(
558
+ reference: ContentItem | undefined,
559
+ references?: Record<string, ContentItem>,
560
+ depth: number = 0,
561
+ externalOrigin?: string,
562
+ ): string | undefined {
563
+ if (reference?.titleInlineContent) {
564
+ return renderInlineContent(reference.titleInlineContent, references, depth + 1, externalOrigin)
565
+ }
566
+ if (reference?.title && reference.role === "symbol") {
567
+ return `\`${reference.title}\``
568
+ }
569
+ return reference?.title
570
+ }
571
+
572
+ interface ResolvedLink {
573
+ title: string
574
+ url: string
575
+ abstract: string
576
+ deprecated: boolean
577
+ }
578
+
579
+ /**
580
+ * Resolve an identifier to a Markdown link, drawing the title and abstract from
581
+ * the page's variants first, then the shared references map, and finally the
582
+ * identifier itself.
583
+ */
584
+ function resolveLink(
585
+ id: string,
586
+ variants?: Variant[],
587
+ references?: Record<string, ContentItem>,
588
+ externalOrigin?: string,
589
+ ): ResolvedLink {
590
+ const info = variants?.find((v) => v.identifier === id)
591
+ const reference = references?.[id]
592
+ const title =
593
+ info?.title ||
594
+ resolveReferenceTitle(reference, references, 0, externalOrigin) ||
595
+ extractTitleFromIdentifier(id)
596
+ const url = convertIdentifierToURL(id, references, externalOrigin)
597
+ const abstract = (info?.abstract ?? reference?.abstract)?.map((a) => a.text).join("") ?? ""
598
+ return { title, url, abstract, deprecated: reference?.deprecated === true }
599
+ }
600
+
580
601
  /**
581
602
  * Render relationship sections
582
603
  */
@@ -592,10 +613,7 @@ function renderRelationships(
592
613
  if (rel.title && rel.identifiers) {
593
614
  markdown += `## ${rel.title}\n\n`
594
615
  for (const id of rel.identifiers) {
595
- const info = variants?.find((v: Variant) => v.identifier === id)
596
- const reference = references?.[id]
597
- const title = info?.title || reference?.title || extractTitleFromIdentifier(id)
598
- const url = convertIdentifierToURL(id, references, externalOrigin)
616
+ const { title, url } = resolveLink(id, variants, references, externalOrigin)
599
617
  markdown += `- [${title}](${url})\n`
600
618
  }
601
619
  markdown += "\n"
@@ -619,34 +637,24 @@ function renderTopicSections(
619
637
  for (const topic of topics) {
620
638
  if (topic.title) {
621
639
  markdown += `## ${topic.title}\n\n`
640
+ }
622
641
 
623
- if (topic.identifiers) {
624
- for (const id of topic.identifiers) {
625
- const info = variants?.find((v: Variant) => v.identifier === id)
626
- const reference = references?.[id]
627
- if (info || reference) {
628
- const title = info?.title || reference?.title || extractTitleFromIdentifier(id)
629
- const url = convertIdentifierToURL(id, references, externalOrigin)
630
- const abstract = info?.abstract
631
- ? info.abstract.map((a: { text: string }) => a.text).join("")
632
- : reference?.abstract
633
- ? reference.abstract.map((a: { text: string }) => a.text).join("")
634
- : ""
635
-
636
- const deprecatedLabel = reference?.deprecated ? " *(Deprecated)*" : ""
637
- markdown += `- [${title}](${url})${deprecatedLabel}`
638
- if (abstract) {
639
- markdown += ` ${abstract}`
640
- }
641
- markdown += "\n"
642
- } else {
643
- const title = extractTitleFromIdentifier(id)
644
- const url = convertIdentifierToURL(id, references, externalOrigin)
645
- markdown += `- [${title}](${url})\n`
646
- }
642
+ if (topic.identifiers) {
643
+ for (const id of topic.identifiers) {
644
+ const { title, url, abstract, deprecated } = resolveLink(
645
+ id,
646
+ variants,
647
+ references,
648
+ externalOrigin,
649
+ )
650
+ const deprecatedLabel = deprecated ? " *(Deprecated)*" : ""
651
+ markdown += `- [${title}](${url})${deprecatedLabel}`
652
+ if (abstract) {
653
+ markdown += ` ${abstract}`
647
654
  }
648
655
  markdown += "\n"
649
656
  }
657
+ markdown += "\n"
650
658
  }
651
659
  }
652
660
 
@@ -721,10 +729,7 @@ function renderSeeAlso(
721
729
  if (section.title && section.identifiers) {
722
730
  markdown += `## ${section.title}\n\n`
723
731
  for (const id of section.identifiers) {
724
- const info = variants?.find((v: Variant) => v.identifier === id)
725
- const reference = references?.[id]
726
- const title = info?.title || reference?.title || extractTitleFromIdentifier(id)
727
- const url = convertIdentifierToURL(id, references, externalOrigin)
732
+ const { title, url } = resolveLink(id, variants, references, externalOrigin)
728
733
  markdown += `- [${title}](${url})\n`
729
734
  }
730
735
  markdown += "\n"
@@ -780,6 +785,9 @@ function getDeprecationMessageFromPlatforms(platforms?: Platform[]): string | nu
780
785
 
781
786
  const DEFAULT_DEPRECATION_MESSAGE = "This symbol is deprecated."
782
787
 
788
+ /** Leading paragraph for callouts that flag deprecated APIs. */
789
+ const DEPRECATED_LEAD = "**Deprecated**"
790
+
783
791
  /**
784
792
  * Resolve deprecation notice text from DocC JSON (without rendering the callout).
785
793
  */
@@ -826,28 +834,7 @@ function renderDeprecationNotice(
826
834
  return ""
827
835
  }
828
836
 
829
- const quotedBody = body.replace(/\n/g, "\n> ")
830
- return `> [!WARNING]\n> **Deprecated**\n>\n> ${quotedBody}\n\n`
831
- }
832
-
833
- /**
834
- * Map aside style to GitHub-style callout
835
- */
836
- function mapAsideStyleToCallout(style: string): string {
837
- switch (style.toLowerCase()) {
838
- case "warning":
839
- return "WARNING"
840
- case "important":
841
- return "IMPORTANT"
842
- case "caution":
843
- return "CAUTION"
844
- case "tip":
845
- return "TIP"
846
- case "deprecated":
847
- return "WARNING"
848
- default:
849
- return "NOTE"
850
- }
837
+ return formatCallout("WARNING", body, DEPRECATED_LEAD)
851
838
  }
852
839
 
853
840
  /**
@@ -893,34 +880,3 @@ function rewriteDocumentationPath(path: string | undefined, externalOrigin?: str
893
880
 
894
881
  return `/external/${externalOrigin}${path}`
895
882
  }
896
-
897
- /**
898
- * Extract title from identifier
899
- */
900
- export function extractTitleFromIdentifier(identifier: string): string {
901
- const parts = identifier.split("/")
902
- const lastPart = parts[parts.length - 1]
903
-
904
- // Handle disambiguation suffixes (e.g., "body-8kl5o" -> "body", "init(exactly:)-63925" -> "init(exactly:)")
905
- const disambiguationMatch = lastPart.match(/^(.+?)(?:-\w+)?$/)
906
- if (disambiguationMatch) {
907
- const baseName = disambiguationMatch[1]
908
-
909
- // If it looks like a method signature (contains parentheses), preserve it
910
- if (baseName.includes("(") && baseName.includes(")")) {
911
- return baseName
912
- }
913
-
914
- // For simple identifiers, convert camelCase to readable format
915
- return baseName
916
- .replace(/([a-z])([A-Z])/g, "$1 $2")
917
- .replace(/\s+/g, " ")
918
- .trim()
919
- }
920
-
921
- // Fallback: convert camelCase to readable format
922
- return lastPart
923
- .replace(/([a-z])([A-Z])/g, "$1 $2")
924
- .replace(/\s+/g, " ")
925
- .trim()
926
- }
package/src/lib/search.ts CHANGED
@@ -15,14 +15,24 @@ export interface SearchResponse {
15
15
  }
16
16
 
17
17
  // Apple's current search backend, discovered from
18
- // https://developer.apple.com/search/scripts/search.js (May 2026)
18
+ // https://developer.apple.com/search/scripts/search.js (September 2026)
19
19
  //
20
20
  // Historical context:
21
21
  // - The legacy /search/ HTML scraper broke when Apple switched to a JS-rendered SPA
22
22
  // - PR #54 upstream (NSHipster/sosumi.ai) targeted /search/services/search.php with
23
23
  // NDJSON-style streamed events; that endpoint is now also gone (404)
24
- // - The current backend is a plain JSON POST API on Apple's MSC infrastructure
25
- const APPLE_SEARCH_SERVICE_URL = "https://devintserv.msc.sbz.apple.com/api/v1/search"
24
+ // - /api/v1/search replaced it, and now 404s in turn
25
+ // - The current backend is /api/v1/query, which answers only in JSONL:
26
+ // one event per line, each tagged with a `kind`
27
+ const APPLE_SEARCH_SERVICE_URL = "https://devintserv.msc.sbz.apple.com/api/v1/query"
28
+
29
+ // The backend rejects a request that does not name the response channels it wants.
30
+ // `quickSearch` carries the top typeahead matches in a single event;
31
+ // `search` streams the full ranked list.
32
+ // The third channel Apple's own page requests, `ask`, is a generated answer,
33
+ // which is not what this service reports.
34
+ const INCLUDED_RESPONSES = ["quickSearch", "search"]
35
+
26
36
  const DEFAULT_TARGET_RESULT_LOCALE = "en"
27
37
  const TARGET_RESULT_LOCALE_BY_BASE_NAME = new Map([
28
38
  ["en", "en"],
@@ -49,7 +59,7 @@ async function searchAppleDeveloperDocsViaService(query: string): Promise<Search
49
59
  method: "POST",
50
60
  headers: {
51
61
  "Content-Type": "application/json",
52
- Accept: "application/json",
62
+ Accept: "application/jsonl",
53
63
  // The MSC backend requires a browser-style Origin/Referer pair to accept the request.
54
64
  Origin: "https://developer.apple.com",
55
65
  Referer: "https://developer.apple.com/search/",
@@ -58,6 +68,7 @@ async function searchAppleDeveloperDocsViaService(query: string): Promise<Search
58
68
  body: JSON.stringify({
59
69
  text: query,
60
70
  targetResultLocale: resolveTargetResultLocale(),
71
+ includedResponses: INCLUDED_RESPONSES,
61
72
  }),
62
73
  })
63
74
 
@@ -65,36 +76,104 @@ async function searchAppleDeveloperDocsViaService(query: string): Promise<Search
65
76
  throw new Error(`Search request failed: ${response.status}`)
66
77
  }
67
78
 
68
- const data = await readSearchResponseJson(response)
69
- const rawResults = Array.isArray(data.results) ? (data.results as unknown[]) : []
70
- return extractSearchResults(rawResults)
79
+ return extractSearchResults(await readSearchResponseItems(response))
80
+ }
81
+
82
+ // The response body is JSONL: one JSON object per line.
83
+ // A `quickSearch` event carries its results inline,
84
+ // while `search` events stream the full list as diffs against a JSON text buffer,
85
+ // so those results can only be read once every line has been applied.
86
+ // Events of any other kind — `quickSearchFinished`, `searchFinished`, `ask` —
87
+ // are not part of the result set and are skipped.
88
+ async function readSearchResponseItems(response: Response): Promise<unknown[]> {
89
+ const items: unknown[] = []
90
+ let streamedSearch = ""
91
+
92
+ for (const line of (await response.text()).split("\n")) {
93
+ const trimmed = line.trim()
94
+ if (!trimmed) {
95
+ continue
96
+ }
97
+
98
+ const event = parseJson(trimmed)
99
+ if (!isJsonRecord(event)) {
100
+ continue
101
+ }
102
+
103
+ if (event.kind === "quickSearch") {
104
+ items.push(...resultsOf(event.response))
105
+ } else if (event.kind === "search") {
106
+ streamedSearch = applySearchDiff(streamedSearch, event.diff)
107
+ }
108
+ }
109
+
110
+ if (streamedSearch) {
111
+ items.push(...resultsOf(parseJson(streamedSearch)))
112
+ }
113
+
114
+ return items
115
+ }
116
+
117
+ // Each `search` event appends to the buffer,
118
+ // after dropping `removeLast` characters from the end of what came before.
119
+ function applySearchDiff(buffer: string, diff: unknown): string {
120
+ if (!isJsonRecord(diff)) {
121
+ return buffer
122
+ }
123
+
124
+ const removeLast = typeof diff.removeLast === "number" ? diff.removeLast : 0
125
+ const append = typeof diff.append === "string" ? diff.append : ""
126
+
127
+ return buffer.slice(0, Math.max(0, buffer.length - removeLast)) + append
71
128
  }
72
129
 
73
- async function readSearchResponseJson(response: Response): Promise<JsonRecord> {
130
+ function parseJson(text: string): unknown {
74
131
  try {
75
- const data = await response.json()
76
- return isJsonRecord(data) ? data : {}
132
+ return JSON.parse(text)
77
133
  } catch {
78
134
  throw new Error("Search response was not valid JSON")
79
135
  }
80
136
  }
81
137
 
138
+ function resultsOf(container: unknown): unknown[] {
139
+ if (!isJsonRecord(container)) {
140
+ return []
141
+ }
142
+
143
+ return Array.isArray(container.results) ? container.results : []
144
+ }
145
+
146
+ // The same page can be reported by more than one channel,
147
+ // so keep the first mention of each URL and drop the rest.
82
148
  function extractSearchResults(items: unknown[]): SearchResult[] {
149
+ const seen = new Set<string>()
150
+
83
151
  return items.flatMap((item) => {
84
152
  const result = normalizeSearchResult(item)
85
- return result ? [result] : []
153
+ if (!result || seen.has(result.url)) {
154
+ return []
155
+ }
156
+
157
+ seen.add(result.url)
158
+ return [result]
86
159
  })
87
160
  }
88
161
 
162
+ // A result is `{ metadata, origin }`, and its `metadata.metadataKind` says how to read it:
163
+ // - "documentation" for reference pages, with singular fields
164
+ // - "developer" for WWDC sessions and other media, with parallel arrays
165
+ // - "webPage" for marketing and swift.org pages, keyed by `sourceURL`
89
166
  function normalizeSearchResult(item: unknown): SearchResult | null {
90
- if (!isJsonRecord(item)) {
167
+ const record = unwrapResultValue(item)
168
+ if (!record || !isJsonRecord(record.metadata)) {
91
169
  return null
92
170
  }
93
171
 
94
- const documentation = extractMetadataRecord(item.documentation)
95
- if (documentation) {
96
- const title = stringValue(documentation.title)
97
- const url = stringValue(documentation.permalink)
172
+ const metadata = record.metadata
173
+
174
+ if (metadata.metadataKind === "documentation") {
175
+ const title = stringValue(metadata.title)
176
+ const url = stringValue(metadata.permalink)
98
177
  if (!title || !url) {
99
178
  return null
100
179
  }
@@ -102,17 +181,16 @@ function normalizeSearchResult(item: unknown): SearchResult | null {
102
181
  return {
103
182
  title,
104
183
  url,
105
- description: stringValue(documentation.description) ?? "",
106
- breadcrumbs: splitHierarchy(stringValue(documentation.hierarchy)),
107
- tags: compactStrings([stringValue(documentation.kind)]),
184
+ description: stringValue(metadata.description) ?? "",
185
+ breadcrumbs: splitHierarchy(stringValue(metadata.hierarchy)),
186
+ tags: compactStrings([stringValue(metadata.kind)]),
108
187
  type: "documentation",
109
188
  }
110
189
  }
111
190
 
112
- const developer = extractMetadataRecord(item.developer)
113
- if (developer) {
114
- const title = firstString(developer.titles)
115
- const url = firstString(developer.permalinks)
191
+ if (metadata.metadataKind === "developer") {
192
+ const title = firstString(metadata.titles)
193
+ const url = firstString(metadata.permalinks)
116
194
  if (!title || !url) {
117
195
  return null
118
196
  }
@@ -120,20 +198,19 @@ function normalizeSearchResult(item: unknown): SearchResult | null {
120
198
  return {
121
199
  title,
122
200
  url,
123
- description: firstString(developer.descriptions) ?? "",
124
- breadcrumbs: compactStrings([firstString(developer.projectNames)]),
201
+ description: firstString(metadata.descriptions) ?? "",
202
+ breadcrumbs: compactStrings([firstString(metadata.projectNames)]),
125
203
  tags: compactStrings([
126
- firstString(developer.itemTypes),
127
- firstString(developer.deliveryLanguageCodes),
204
+ firstString(metadata.itemTypes),
205
+ firstString(metadata.deliveryLanguageCodes),
128
206
  ]),
129
- type: (firstString(developer.itemTypes) ?? "developer").toLowerCase(),
207
+ type: (firstString(metadata.itemTypes) ?? "developer").toLowerCase(),
130
208
  }
131
209
  }
132
210
 
133
- const devsite = extractMetadataRecord(item.devsite)
134
- if (devsite) {
135
- const title = stringValue(devsite.title)
136
- const url = stringValue(devsite.sourceURL)
211
+ if (metadata.metadataKind === "webPage") {
212
+ const title = stringValue(metadata.title)
213
+ const url = stringValue(metadata.sourceURL)
137
214
  if (!title || !url) {
138
215
  return null
139
216
  }
@@ -141,25 +218,7 @@ function normalizeSearchResult(item: unknown): SearchResult | null {
141
218
  return {
142
219
  title,
143
220
  url,
144
- description: stringValue(devsite.description) ?? "",
145
- breadcrumbs: [],
146
- tags: [],
147
- type: "general",
148
- }
149
- }
150
-
151
- const swiftdocs = extractMetadataRecord(item.swiftdocs)
152
- if (swiftdocs) {
153
- const title = stringValue(swiftdocs.title)
154
- const url = stringValue(swiftdocs.sourceURL)
155
- if (!title || !url) {
156
- return null
157
- }
158
-
159
- return {
160
- title,
161
- url,
162
- description: stringValue(swiftdocs.description) ?? "",
221
+ description: stringValue(metadata.description) ?? "",
163
222
  breadcrumbs: [],
164
223
  tags: [],
165
224
  type: "general",
@@ -169,13 +228,14 @@ function normalizeSearchResult(item: unknown): SearchResult | null {
169
228
  return null
170
229
  }
171
230
 
172
- function extractMetadataRecord(container: unknown): JsonRecord | null {
173
- if (!isJsonRecord(container)) {
231
+ // Streamed `search` results wrap their payload in `value` alongside a match excerpt,
232
+ // while `quickSearch` results carry it directly.
233
+ function unwrapResultValue(item: unknown): JsonRecord | null {
234
+ if (!isJsonRecord(item)) {
174
235
  return null
175
236
  }
176
237
 
177
- const metadata = container.metadata
178
- return isJsonRecord(metadata) ? metadata : null
238
+ return isJsonRecord(item.value) ? item.value : item
179
239
  }
180
240
 
181
241
  function isJsonRecord(value: unknown): value is JsonRecord {
package/src/lib/types.ts CHANGED
@@ -51,6 +51,13 @@ export interface ContentItem {
51
51
  title?: string
52
52
  name?: string
53
53
 
54
+ /**
55
+ * Rich inline rendering of a reference's title (e.g. a symbol name wrapped in
56
+ * a `codeVoice` span). Present on entries in the `references` map and preferred
57
+ * over `title` so code spans survive inside link text.
58
+ */
59
+ titleInlineContent?: ContentItem[]
60
+
54
61
  // Tokenized content
55
62
  tokens?: Token[]
56
63
 
@@ -69,6 +76,11 @@ export interface ContentItem {
69
76
  // Structural properties
70
77
  level?: number
71
78
  style?: string
79
+ /**
80
+ * DocC reference role (e.g. `"symbol"`, `"collection"`, `"article"`).
81
+ * Used to decide whether a plain title should be rendered as a code span.
82
+ */
83
+ role?: string
72
84
  identifier?: string
73
85
  identifiers?: string[]
74
86
  url?: string