@nshipster/sosumi 1.0.5 → 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.
@@ -2,11 +2,24 @@
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,
8
20
  Declaration,
9
21
  IndexContentItem,
22
+ Platform,
10
23
  PossibleValueItem,
11
24
  PropertyItem,
12
25
  TopicSection,
@@ -67,6 +80,8 @@ export async function renderFromJSON(
67
80
  }
68
81
  }
69
82
 
83
+ markdown += renderDeprecationNotice(jsonData, jsonData.references, options.externalOrigin)
84
+
70
85
  // Add declaration
71
86
  if (jsonData.primaryContentSections) {
72
87
  const declarationSection = jsonData.primaryContentSections.find(
@@ -193,6 +208,10 @@ function generateFrontMatterFromJSON(jsonData: AppleDocJSON, sourceUrl: string):
193
208
  frontMatter.source = sourceUrl
194
209
  frontMatter.timestamp = new Date().toISOString()
195
210
 
211
+ if (isSymbolDeprecated(jsonData)) {
212
+ frontMatter.deprecated = "true"
213
+ }
214
+
196
215
  // Convert to YAML format
197
216
  const yamlLines = Object.entries(frontMatter).map(([key, value]) => `${key}: ${value}`)
198
217
  return `---\n${yamlLines.join("\n")}\n---\n\n`
@@ -228,14 +247,6 @@ function generateBreadcrumbs(sourceUrl: string, externalOrigin?: string): string
228
247
  return `${breadcrumbs}\n\n`
229
248
  }
230
249
 
231
- /**
232
- * Map a DocC language code to a Markdown code-fence identifier. DocC uses
233
- * `occ` for Objective-C internally, but Markdown highlighters expect `objc`.
234
- */
235
- function normalizeFenceLanguage(syntax: string): string {
236
- return syntax === "occ" ? "objc" : syntax
237
- }
238
-
239
250
  /**
240
251
  * Tab titles DocC uses for language-variant code examples. Used to detect
241
252
  * whether a `tabNavigator` is a language switch (so we can default to Swift)
@@ -392,9 +403,9 @@ function renderContentArray(
392
403
  externalOrigin?: string,
393
404
  ): string {
394
405
  // Prevent infinite recursion by limiting depth
395
- if (depth > 50) {
406
+ if (depth > MAX_CONTENT_DEPTH) {
396
407
  console.warn("Maximum recursion depth reached in renderContentArray")
397
- return "[Content too deeply nested]"
408
+ return CONTENT_TOO_DEEP
398
409
  }
399
410
 
400
411
  let markdown = ""
@@ -406,53 +417,29 @@ function renderContentArray(
406
417
  markdown += `${hashes} ${item.text}\n\n`
407
418
  } else if (item.type === "paragraph") {
408
419
  if (item.inlineContent) {
409
- 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)
410
423
  markdown += `${text}\n\n`
411
424
  }
412
425
  } else if (item.type === "codeListing") {
413
- let code = ""
414
- if (Array.isArray(item.code)) {
415
- code = item.code.join("\n")
416
- } else {
417
- code = String(item.code || "")
418
- }
419
- const syntax = normalizeFenceLanguage(item.syntax || "swift")
420
-
421
- markdown += `\`\`\`${syntax}\n${code}\n\`\`\`\n\n`
422
- } else if (item.type === "unorderedList") {
426
+ markdown += formatCodeBlock(item.code, item.syntax)
427
+ } else if (item.type === "unorderedList" || item.type === "orderedList") {
423
428
  if (item.items) {
424
- for (const listItem of item.items) {
425
- const itemText = renderContentArray(
426
- listItem.content || [],
427
- references,
428
- depth + 1,
429
- externalOrigin,
430
- )
431
- markdown += `- ${itemText.replace(/\n\n$/, "")}\n`
432
- }
433
- markdown += "\n"
434
- }
435
- } else if (item.type === "orderedList") {
436
- if (item.items) {
437
- item.items.forEach((listItem: ContentItem, index: number) => {
438
- const itemText = renderContentArray(
439
- listItem.content || [],
440
- references,
441
- depth + 1,
442
- externalOrigin,
443
- )
444
- markdown += `${index + 1}. ${itemText.replace(/\n\n$/, "")}\n`
445
- })
446
- markdown += "\n"
429
+ markdown += formatList(
430
+ item.items.map((listItem) =>
431
+ renderContentArray(listItem.content || [], references, depth + 1, externalOrigin),
432
+ ),
433
+ item.type === "orderedList",
434
+ )
447
435
  }
448
436
  } else if (item.type === "aside") {
449
437
  const style = item.style || "note"
450
- const calloutType = mapAsideStyleToCallout(style)
451
438
  const asideContent = item.content
452
439
  ? renderContentArray(item.content, references, depth + 1, externalOrigin)
453
440
  : ""
454
- const cleanContent = asideContent.trim().replace(/\n/g, "\n> ")
455
- markdown += `> [!${calloutType}]\n> ${cleanContent}\n\n`
441
+ const lead = style.toLowerCase() === "deprecated" ? DEPRECATED_LEAD : undefined
442
+ markdown += formatCallout(mapAsideStyleToCallout(style), asideContent, lead)
456
443
  } else if (item.type === "table") {
457
444
  markdown += renderTable(item, references, depth, externalOrigin)
458
445
  } else if (item.type === "tabNavigator" && item.tabs?.length) {
@@ -499,27 +486,17 @@ function renderTable(
499
486
  header?: string
500
487
  rows?: ContentItem[][][] // rows[rowIndex][cellIndex] = ContentItem[]
501
488
  }
502
- const rows = table.rows ?? []
503
- if (rows.length === 0) return ""
504
-
505
- const escapeCell = (s: string) => s.replace(/\|/g, "\\|").replace(/\n/g, " ").trim()
506
- const renderCell = (cell: ContentItem | ContentItem[]) => {
507
- const items = Array.isArray(cell) ? cell : [cell]
508
- const s = renderContentArray(items, references, depth + 1, externalOrigin)
509
- return escapeCell(s)
510
- }
511
-
512
- const firstRowIsHeader = table.header === "row"
513
- let markdown = ""
514
- rows.forEach((row, rowIndex) => {
515
- const cells = row.map((c) => renderCell(c))
516
- if (cells.length === 0) return
517
- markdown += `| ${cells.join(" | ")} |\n`
518
- if (firstRowIsHeader && rowIndex === 0) {
519
- markdown += `| ${cells.map(() => "---").join(" | ")} |\n`
520
- }
521
- })
522
- 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")
523
500
  }
524
501
 
525
502
  /**
@@ -532,9 +509,9 @@ function renderInlineContent(
532
509
  externalOrigin?: string,
533
510
  ): string {
534
511
  // Prevent infinite recursion by limiting depth
535
- if (depth > 20) {
512
+ if (depth > MAX_INLINE_DEPTH) {
536
513
  console.warn("Maximum recursion depth reached in renderInlineContent")
537
- return "[Inline content too deeply nested]"
514
+ return INLINE_CONTENT_TOO_DEEP
538
515
  }
539
516
 
540
517
  return inlineContent
@@ -544,9 +521,11 @@ function renderInlineContent(
544
521
  } else if (item.type === "codeVoice") {
545
522
  return `\`${item.code}\``
546
523
  } else if (item.type === "reference") {
524
+ const reference = item.identifier ? references?.[item.identifier] : undefined
547
525
  const title =
548
526
  item.title ||
549
527
  item.text ||
528
+ resolveReferenceTitle(reference, references, depth, externalOrigin) ||
550
529
  (item.identifier ? extractTitleFromIdentifier(item.identifier) : "")
551
530
  const url = item.identifier
552
531
  ? convertIdentifierToURL(item.identifier, references, externalOrigin)
@@ -569,6 +548,56 @@ function renderInlineContent(
569
548
  .join("")
570
549
  }
571
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
+
572
601
  /**
573
602
  * Render relationship sections
574
603
  */
@@ -584,10 +613,7 @@ function renderRelationships(
584
613
  if (rel.title && rel.identifiers) {
585
614
  markdown += `## ${rel.title}\n\n`
586
615
  for (const id of rel.identifiers) {
587
- const info = variants?.find((v: Variant) => v.identifier === id)
588
- const reference = references?.[id]
589
- const title = info?.title || reference?.title || extractTitleFromIdentifier(id)
590
- const url = convertIdentifierToURL(id, references, externalOrigin)
616
+ const { title, url } = resolveLink(id, variants, references, externalOrigin)
591
617
  markdown += `- [${title}](${url})\n`
592
618
  }
593
619
  markdown += "\n"
@@ -611,33 +637,24 @@ function renderTopicSections(
611
637
  for (const topic of topics) {
612
638
  if (topic.title) {
613
639
  markdown += `## ${topic.title}\n\n`
640
+ }
614
641
 
615
- if (topic.identifiers) {
616
- for (const id of topic.identifiers) {
617
- const info = variants?.find((v: Variant) => v.identifier === id)
618
- const reference = references?.[id]
619
- if (info || reference) {
620
- const title = info?.title || reference?.title || extractTitleFromIdentifier(id)
621
- const url = convertIdentifierToURL(id, references, externalOrigin)
622
- const abstract = info?.abstract
623
- ? info.abstract.map((a: { text: string }) => a.text).join("")
624
- : reference?.abstract
625
- ? reference.abstract.map((a: { text: string }) => a.text).join("")
626
- : ""
627
-
628
- markdown += `- [${title}](${url})`
629
- if (abstract) {
630
- markdown += ` ${abstract}`
631
- }
632
- markdown += "\n"
633
- } else {
634
- const title = extractTitleFromIdentifier(id)
635
- const url = convertIdentifierToURL(id, references, externalOrigin)
636
- markdown += `- [${title}](${url})\n`
637
- }
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}`
638
654
  }
639
655
  markdown += "\n"
640
656
  }
657
+ markdown += "\n"
641
658
  }
642
659
  }
643
660
 
@@ -712,10 +729,7 @@ function renderSeeAlso(
712
729
  if (section.title && section.identifiers) {
713
730
  markdown += `## ${section.title}\n\n`
714
731
  for (const id of section.identifiers) {
715
- const info = variants?.find((v: Variant) => v.identifier === id)
716
- const reference = references?.[id]
717
- const title = info?.title || reference?.title || extractTitleFromIdentifier(id)
718
- const url = convertIdentifierToURL(id, references, externalOrigin)
732
+ const { title, url } = resolveLink(id, variants, references, externalOrigin)
719
733
  markdown += `- [${title}](${url})\n`
720
734
  }
721
735
  markdown += "\n"
@@ -726,23 +740,101 @@ function renderSeeAlso(
726
740
  }
727
741
 
728
742
  /**
729
- * Map aside style to GitHub-style callout
743
+ * Whether the symbol page represents a deprecated API.
744
+ */
745
+ function isSymbolDeprecated(jsonData: AppleDocJSON): boolean {
746
+ return getDeprecationNoticeBody(jsonData, jsonData.references) !== null
747
+ }
748
+
749
+ /**
750
+ * Collect a deprecation message from platform availability metadata.
751
+ */
752
+ function getDeprecationMessageFromPlatforms(platforms?: Platform[]): string | null {
753
+ if (!platforms?.length) {
754
+ return null
755
+ }
756
+
757
+ const deprecatedPlatforms = platforms.filter(
758
+ (platform) => platform.deprecated === true || platform.deprecatedAt,
759
+ )
760
+ if (deprecatedPlatforms.length === 0) {
761
+ return null
762
+ }
763
+
764
+ const uniqueMessages = [
765
+ ...new Set(
766
+ deprecatedPlatforms
767
+ .map((platform) => platform.message?.trim())
768
+ .filter((message): message is string => Boolean(message)),
769
+ ),
770
+ ]
771
+
772
+ if (uniqueMessages.length === 1) {
773
+ return uniqueMessages[0]
774
+ }
775
+
776
+ if (uniqueMessages.length > 1) {
777
+ return deprecatedPlatforms
778
+ .filter((platform) => platform.message)
779
+ .map((platform) => `**${platform.name}:** ${platform.message}`)
780
+ .join("\n")
781
+ }
782
+
783
+ return DEFAULT_DEPRECATION_MESSAGE
784
+ }
785
+
786
+ const DEFAULT_DEPRECATION_MESSAGE = "This symbol is deprecated."
787
+
788
+ /** Leading paragraph for callouts that flag deprecated APIs. */
789
+ const DEPRECATED_LEAD = "**Deprecated**"
790
+
791
+ /**
792
+ * Resolve deprecation notice text from DocC JSON (without rendering the callout).
793
+ */
794
+ function getDeprecationNoticeBody(
795
+ jsonData: AppleDocJSON,
796
+ references?: Record<string, ContentItem>,
797
+ externalOrigin?: string,
798
+ ): string | null {
799
+ if (jsonData.deprecationSummary?.length) {
800
+ const summary = renderContentArray(
801
+ jsonData.deprecationSummary,
802
+ references,
803
+ 0,
804
+ externalOrigin,
805
+ ).trim()
806
+ if (summary) {
807
+ return summary
808
+ }
809
+ }
810
+
811
+ const platformMessage = getDeprecationMessageFromPlatforms(jsonData.metadata?.platforms)
812
+ if (platformMessage) {
813
+ return platformMessage
814
+ }
815
+
816
+ const identifier = jsonData.identifier?.url
817
+ if (identifier && references?.[identifier]?.deprecated === true) {
818
+ return DEFAULT_DEPRECATION_MESSAGE
819
+ }
820
+
821
+ return null
822
+ }
823
+
824
+ /**
825
+ * Render a deprecation callout for deprecated symbol pages.
730
826
  */
731
- function mapAsideStyleToCallout(style: string): string {
732
- switch (style.toLowerCase()) {
733
- case "warning":
734
- return "WARNING"
735
- case "important":
736
- return "IMPORTANT"
737
- case "caution":
738
- return "CAUTION"
739
- case "tip":
740
- return "TIP"
741
- case "deprecated":
742
- return "WARNING"
743
- default:
744
- return "NOTE"
827
+ function renderDeprecationNotice(
828
+ jsonData: AppleDocJSON,
829
+ references?: Record<string, ContentItem>,
830
+ externalOrigin?: string,
831
+ ): string {
832
+ const body = getDeprecationNoticeBody(jsonData, references, externalOrigin)
833
+ if (!body) {
834
+ return ""
745
835
  }
836
+
837
+ return formatCallout("WARNING", body, DEPRECATED_LEAD)
746
838
  }
747
839
 
748
840
  /**
@@ -788,34 +880,3 @@ function rewriteDocumentationPath(path: string | undefined, externalOrigin?: str
788
880
 
789
881
  return `/external/${externalOrigin}${path}`
790
882
  }
791
-
792
- /**
793
- * Extract title from identifier
794
- */
795
- export function extractTitleFromIdentifier(identifier: string): string {
796
- const parts = identifier.split("/")
797
- const lastPart = parts[parts.length - 1]
798
-
799
- // Handle disambiguation suffixes (e.g., "body-8kl5o" -> "body", "init(exactly:)-63925" -> "init(exactly:)")
800
- const disambiguationMatch = lastPart.match(/^(.+?)(?:-\w+)?$/)
801
- if (disambiguationMatch) {
802
- const baseName = disambiguationMatch[1]
803
-
804
- // If it looks like a method signature (contains parentheses), preserve it
805
- if (baseName.includes("(") && baseName.includes(")")) {
806
- return baseName
807
- }
808
-
809
- // For simple identifiers, convert camelCase to readable format
810
- return baseName
811
- .replace(/([a-z])([A-Z])/g, "$1 $2")
812
- .replace(/\s+/g, " ")
813
- .trim()
814
- }
815
-
816
- // Fallback: convert camelCase to readable format
817
- return lastPart
818
- .replace(/([a-z])([A-Z])/g, "$1 $2")
819
- .replace(/\s+/g, " ")
820
- .trim()
821
- }