@nshipster/sosumi 1.0.6 → 1.0.7
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.
- package/README.md +44 -1
- package/package.json +4 -2
- package/public/index.html +3 -1
- package/public/llms.txt +1 -1
- package/public/robots.txt +1 -0
- package/public/webmcp.js +71 -0
- package/src/index.ts +112 -1
- package/src/lib/a2a.ts +90 -0
- package/src/lib/external/fetch.ts +3 -0
- package/src/lib/hig/render.ts +94 -73
- package/src/lib/markdown.ts +115 -0
- package/src/lib/mcp.ts +150 -115
- package/src/lib/reference/render.ts +112 -156
- package/src/lib/search.ts +115 -55
- package/src/lib/types.ts +12 -0
- package/src/lib/url.ts +35 -0
- package/src/lib/webbotauth.ts +261 -0
- package/src/lib/webmcp.ts +38 -0
- package/wrangler.jsonc +1 -1
|
@@ -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 >
|
|
406
|
+
if (depth > MAX_CONTENT_DEPTH) {
|
|
403
407
|
console.warn("Maximum recursion depth reached in renderContentArray")
|
|
404
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
listItem.content || [],
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
|
462
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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 >
|
|
512
|
+
if (depth > MAX_INLINE_DEPTH) {
|
|
544
513
|
console.warn("Maximum recursion depth reached in renderInlineContent")
|
|
545
|
-
return
|
|
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
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
// -
|
|
25
|
-
|
|
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/
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
130
|
+
function parseJson(text: string): unknown {
|
|
74
131
|
try {
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
+
const record = unwrapResultValue(item)
|
|
168
|
+
if (!record || !isJsonRecord(record.metadata)) {
|
|
91
169
|
return null
|
|
92
170
|
}
|
|
93
171
|
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
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(
|
|
106
|
-
breadcrumbs: splitHierarchy(stringValue(
|
|
107
|
-
tags: compactStrings([stringValue(
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
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(
|
|
124
|
-
breadcrumbs: compactStrings([firstString(
|
|
201
|
+
description: firstString(metadata.descriptions) ?? "",
|
|
202
|
+
breadcrumbs: compactStrings([firstString(metadata.projectNames)]),
|
|
125
203
|
tags: compactStrings([
|
|
126
|
-
firstString(
|
|
127
|
-
firstString(
|
|
204
|
+
firstString(metadata.itemTypes),
|
|
205
|
+
firstString(metadata.deliveryLanguageCodes),
|
|
128
206
|
]),
|
|
129
|
-
type: (firstString(
|
|
207
|
+
type: (firstString(metadata.itemTypes) ?? "developer").toLowerCase(),
|
|
130
208
|
}
|
|
131
209
|
}
|
|
132
210
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
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(
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|