@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,8 +2,19 @@
2
2
  * Human Interface Guidelines (HIG) rendering functionality
3
3
  */
4
4
 
5
- import { extractTitleFromIdentifier } from "../reference/render"
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"
6
16
  import type { ContentItem, TextFragment } from "../types"
17
+ import { extractTitleFromIdentifier } from "../url"
7
18
  import type {
8
19
  HIGExternalReference,
9
20
  HIGImageReference,
@@ -184,7 +195,14 @@ function generateHIGBreadcrumbs(sourceUrl: string): string {
184
195
  function renderHIGContent(
185
196
  content: ContentItem[],
186
197
  references: Record<string, HIGReference | HIGImageReference | HIGExternalReference>,
198
+ depth: number = 0,
187
199
  ): string {
200
+ // Prevent infinite recursion by limiting depth
201
+ if (depth > MAX_CONTENT_DEPTH) {
202
+ console.warn("Maximum recursion depth reached in renderHIGContent")
203
+ return CONTENT_TOO_DEEP
204
+ }
205
+
188
206
  let markdown = ""
189
207
 
190
208
  for (const item of content) {
@@ -212,7 +230,7 @@ function renderHIGContent(
212
230
  markdown += "\n"
213
231
  } else {
214
232
  // Handle other content types using the existing content renderer
215
- markdown += renderContentItem(item, references)
233
+ markdown += renderContentItem(item, references, depth)
216
234
  }
217
235
  }
218
236
 
@@ -225,6 +243,7 @@ function renderHIGContent(
225
243
  function renderContentItem(
226
244
  item: ContentItem,
227
245
  references: Record<string, HIGReference | HIGImageReference | HIGExternalReference>,
246
+ depth: number = 0,
228
247
  ): string {
229
248
  let markdown = ""
230
249
 
@@ -234,36 +253,26 @@ function renderContentItem(
234
253
  markdown += `${hashes} ${item.text}\n\n`
235
254
  } else if (item.type === "paragraph") {
236
255
  if (item.inlineContent) {
256
+ // Inline nesting is counted separately from block nesting, so a
257
+ // paragraph's inline content starts a fresh depth budget.
237
258
  const text = renderHIGInlineContent(item.inlineContent, references)
238
259
  markdown += `${text}\n\n`
239
260
  }
240
261
  } else if (item.type === "codeListing") {
241
- let code = ""
242
- if (Array.isArray(item.code)) {
243
- code = item.code.join("\n")
244
- } else {
245
- code = String(item.code || "")
246
- }
247
- const syntax = item.syntax || "swift"
248
- markdown += `\`\`\`${syntax}\n${code}\n\`\`\`\n\n`
249
- } else if (item.type === "unorderedList" && item.items) {
250
- for (const listItem of item.items) {
251
- const itemText = renderHIGContent(listItem.content || [], references)
252
- markdown += `- ${itemText.replace(/\n\n$/, "")}\n`
253
- }
254
- markdown += "\n"
255
- } else if (item.type === "orderedList" && item.items) {
256
- item.items.forEach((listItem: ContentItem, index: number) => {
257
- const itemText = renderHIGContent(listItem.content || [], references)
258
- markdown += `${index + 1}. ${itemText.replace(/\n\n$/, "")}\n`
259
- })
260
- markdown += "\n"
262
+ markdown += formatCodeBlock(item.code, item.syntax)
263
+ } else if ((item.type === "unorderedList" || item.type === "orderedList") && item.items) {
264
+ markdown += formatList(
265
+ item.items.map((listItem) => renderHIGContent(listItem.content || [], references, depth + 1)),
266
+ item.type === "orderedList",
267
+ )
261
268
  } else if (item.type === "table") {
262
- markdown += renderHIGTable(item, references)
269
+ markdown += renderHIGTable(item, references, depth)
263
270
  } else if (item.type === "aside") {
264
- markdown += renderHIGAside(item, references)
271
+ markdown += renderHIGAside(item, references, depth)
272
+ } else if (item.type === "tabNavigator") {
273
+ markdown += renderHIGTabNavigator(item, references, depth)
265
274
  } else if (item.type === "row") {
266
- markdown += renderHIGRow(item, references)
275
+ markdown += renderHIGRow(item, references, depth)
267
276
  } else if (item.type === "video") {
268
277
  markdown += renderHIGVideo(item, references)
269
278
  }
@@ -278,34 +287,16 @@ function renderContentItem(
278
287
  function renderHIGTable(
279
288
  item: ContentItem,
280
289
  references: Record<string, HIGReference | HIGImageReference | HIGExternalReference>,
290
+ depth: number = 0,
281
291
  ): string {
282
292
  const table = item as ContentItem & {
283
293
  header?: string
284
294
  rows?: ContentItem[][][]
285
295
  }
286
- const rows = table.rows ?? []
287
- if (rows.length === 0) return ""
288
-
289
- const escapeCell = (s: string) => s.replace(/\|/g, "\\|").replace(/\n/g, " ").trim()
290
- const renderCell = (cell: ContentItem | ContentItem[]) => {
291
- const items = Array.isArray(cell) ? cell : [cell]
292
- const text = renderHIGContent(items, references)
293
- return escapeCell(text)
294
- }
295
-
296
- const firstRowIsHeader = table.header === "row"
297
- let markdown = ""
298
-
299
- rows.forEach((row, rowIndex) => {
300
- const cells = row.map((cell) => renderCell(cell))
301
- if (cells.length === 0) return
302
- markdown += `| ${cells.join(" | ")} |\n`
303
- if (firstRowIsHeader && rowIndex === 0) {
304
- markdown += `| ${cells.map(() => "---").join(" | ")} |\n`
305
- }
306
- })
307
-
308
- return markdown ? `${markdown}\n` : ""
296
+ const rows = (table.rows ?? []).map((row) =>
297
+ row.map((cell) => renderHIGContent(Array.isArray(cell) ? cell : [cell], references, depth + 1)),
298
+ )
299
+ return formatTable(rows, table.header === "row")
309
300
  }
310
301
 
311
302
  /**
@@ -314,14 +305,49 @@ function renderHIGTable(
314
305
  function renderHIGAside(
315
306
  item: ContentItem,
316
307
  references: Record<string, HIGReference | HIGImageReference | HIGExternalReference>,
308
+ depth: number = 0,
317
309
  ): string {
318
310
  const aside = item as ContentItem & { style?: string; name?: string }
319
311
  const rawType = (aside.style || aside.name || "note").toLowerCase()
320
- const calloutType = mapHIGAsideStyleToCallout(rawType)
321
- const asideContent = item.content ? renderHIGContent(item.content, references) : ""
322
- const cleanContent = asideContent.trim().replace(/\n/g, "\n> ")
323
- if (!cleanContent) return ""
324
- return `> [!${calloutType}]\n> ${cleanContent}\n\n`
312
+ const asideContent = item.content ? renderHIGContent(item.content, references, depth + 1) : ""
313
+ return formatCallout(mapAsideStyleToCallout(rawType), asideContent)
314
+ }
315
+
316
+ /**
317
+ * Render a HIG tabNavigator by flattening its tabs into a sequence.
318
+ * Only one tab is visible at a time on the web,
319
+ * so rendering them all keeps the content of every tab
320
+ * (such as the per-Dynamic Type size tables on the Typography page).
321
+ */
322
+ function renderHIGTabNavigator(
323
+ item: ContentItem,
324
+ references: Record<string, HIGReference | HIGImageReference | HIGExternalReference>,
325
+ depth: number = 0,
326
+ ): string {
327
+ const tabs = item.tabs ?? []
328
+ if (tabs.length === 0) return ""
329
+
330
+ let markdown = ""
331
+ for (const tab of tabs) {
332
+ // Tabs usually repeat their title as a heading in their own content,
333
+ // so only add a label when the title would otherwise be lost.
334
+ // A leading heading only stands in for the label when it repeats the title,
335
+ // possibly with a qualifier, as in "Small" and "Small (default 38mm)".
336
+ const label = tab.title?.trim()
337
+ const first = tab.content?.[0]
338
+ const leadingHeading = first?.type === "heading" ? (first.text ?? "").trim() : undefined
339
+ const headingRepeatsTitle =
340
+ label !== undefined &&
341
+ leadingHeading !== undefined &&
342
+ leadingHeading.toLowerCase().startsWith(label.toLowerCase())
343
+ if (label && !headingRepeatsTitle) {
344
+ markdown += `**${label}**\n\n`
345
+ }
346
+ if (tab.content?.length) {
347
+ markdown += renderHIGContent(tab.content, references, depth + 1)
348
+ }
349
+ }
350
+ return markdown
325
351
  }
326
352
 
327
353
  /**
@@ -330,6 +356,7 @@ function renderHIGAside(
330
356
  function renderHIGRow(
331
357
  item: ContentItem,
332
358
  references: Record<string, HIGReference | HIGImageReference | HIGExternalReference>,
359
+ depth: number = 0,
333
360
  ): string {
334
361
  const row = item as ContentItem & {
335
362
  columns?: Array<{
@@ -341,7 +368,7 @@ function renderHIGRow(
341
368
  let markdown = ""
342
369
  for (const column of row.columns) {
343
370
  if (column.content && column.content.length > 0) {
344
- markdown += renderHIGContent(column.content, references)
371
+ markdown += renderHIGContent(column.content, references, depth + 1)
345
372
  }
346
373
  }
347
374
  return markdown
@@ -389,7 +416,14 @@ function renderHIGVideo(
389
416
  function renderHIGInlineContent(
390
417
  inlineContent: ContentItem[],
391
418
  references: Record<string, HIGReference | HIGImageReference | HIGExternalReference>,
419
+ depth: number = 0,
392
420
  ): string {
421
+ // Prevent infinite recursion by limiting depth
422
+ if (depth > MAX_INLINE_DEPTH) {
423
+ console.warn("Maximum recursion depth reached in renderHIGInlineContent")
424
+ return INLINE_CONTENT_TOO_DEEP
425
+ }
426
+
393
427
  return inlineContent
394
428
  .map((item) => {
395
429
  if (item.type === "text") {
@@ -411,11 +445,15 @@ function renderHIGInlineContent(
411
445
  return `[${title}](${url})`
412
446
  } else if (item.type === "emphasis") {
413
447
  return `*${
414
- item.inlineContent ? renderHIGInlineContent(item.inlineContent, references) : ""
448
+ item.inlineContent
449
+ ? renderHIGInlineContent(item.inlineContent, references, depth + 1)
450
+ : ""
415
451
  }*`
416
452
  } else if (item.type === "strong") {
417
453
  return `**${
418
- item.inlineContent ? renderHIGInlineContent(item.inlineContent, references) : ""
454
+ item.inlineContent
455
+ ? renderHIGInlineContent(item.inlineContent, references, depth + 1)
456
+ : ""
419
457
  }**`
420
458
  } else if (item.type === "image" && item.identifier) {
421
459
  const reference = references[item.identifier]
@@ -471,23 +509,6 @@ function renderHIGTopicSections(
471
509
  return markdown
472
510
  }
473
511
 
474
- function mapHIGAsideStyleToCallout(style: string): string {
475
- switch (style.toLowerCase()) {
476
- case "warning":
477
- return "WARNING"
478
- case "important":
479
- return "IMPORTANT"
480
- case "caution":
481
- return "CAUTION"
482
- case "tip":
483
- return "TIP"
484
- case "deprecated":
485
- return "WARNING"
486
- default:
487
- return "NOTE"
488
- }
489
- }
490
-
491
512
  /**
492
513
  * Render HIG table of contents items
493
514
  */
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Markdown formatting and nesting limits shared by the reference and HIG
3
+ * renderers.
4
+ */
5
+
6
+ /** Depth limit guarding against cyclic or pathologically nested block content. */
7
+ export const MAX_CONTENT_DEPTH = 50
8
+
9
+ /**
10
+ * Depth limit guarding against cyclic or pathologically nested inline content.
11
+ * Counted independently of block nesting, since inline content recurses on its
12
+ * own stack.
13
+ */
14
+ export const MAX_INLINE_DEPTH = 20
15
+
16
+ /** Emitted in place of block content that exceeds `MAX_CONTENT_DEPTH`. */
17
+ export const CONTENT_TOO_DEEP = "[Content too deeply nested]"
18
+
19
+ /** Emitted in place of inline content that exceeds `MAX_INLINE_DEPTH`. */
20
+ export const INLINE_CONTENT_TOO_DEEP = "[Inline content too deeply nested]"
21
+
22
+ /**
23
+ * Format rendered content as a Markdown list item, indenting continuation
24
+ * lines (including nested lists) under the marker.
25
+ */
26
+ function formatListItem(marker: string, content: string): string {
27
+ const trimmed = content.replace(/\n+$/, "")
28
+ if (!trimmed) {
29
+ return `${marker.trimEnd()}\n`
30
+ }
31
+
32
+ // Collapse blank lines immediately before a nested list so it stays tight
33
+ // under its parent item; keep other paragraph breaks.
34
+ const tightened = trimmed.replace(/\n{2,}(?=(?:[-*]|\d+\.) )/g, "\n")
35
+ const indent = " ".repeat(marker.length)
36
+ const [first, ...rest] = tightened.split("\n")
37
+ const continuation = rest.map((line) => (line ? indent + line : ""))
38
+ return `${marker}${[first, ...continuation].join("\n")}\n`
39
+ }
40
+
41
+ /**
42
+ * Format pre-rendered item contents as a Markdown list, terminated by a blank
43
+ * line so following blocks aren't absorbed into the last item.
44
+ */
45
+ export function formatList(itemContents: string[], ordered: boolean): string {
46
+ const items = itemContents.map((content, index) =>
47
+ formatListItem(ordered ? `${index + 1}. ` : "- ", content),
48
+ )
49
+ return `${items.join("")}\n`
50
+ }
51
+
52
+ /**
53
+ * Format a DocC code listing as a fenced code block, defaulting to Swift.
54
+ */
55
+ export function formatCodeBlock(code?: string | string[], syntax?: string): string {
56
+ const body = Array.isArray(code) ? code.join("\n") : String(code || "")
57
+ // DocC uses `occ` for Objective-C internally, but highlighters expect `objc`.
58
+ const language = syntax === "occ" ? "objc" : syntax || "swift"
59
+ return `\`\`\`${language}\n${body}\n\`\`\`\n\n`
60
+ }
61
+
62
+ /**
63
+ * Format rendered cell contents as a Markdown table, escaping pipes and
64
+ * flattening newlines that would otherwise break a row.
65
+ */
66
+ export function formatTable(rows: string[][], hasHeaderRow: boolean): string {
67
+ let markdown = ""
68
+ rows.forEach((row, index) => {
69
+ if (row.length === 0) return
70
+ const cells = row.map((cell) => cell.replace(/\|/g, "\\|").replace(/\n/g, " ").trim())
71
+ markdown += `| ${cells.join(" | ")} |\n`
72
+ if (hasHeaderRow && index === 0) {
73
+ markdown += `| ${cells.map(() => "---").join(" | ")} |\n`
74
+ }
75
+ })
76
+ return markdown ? `${markdown}\n` : ""
77
+ }
78
+
79
+ /**
80
+ * Format rendered content as a GitHub-style callout. An optional lead becomes
81
+ * the callout's first paragraph, ahead of the content.
82
+ */
83
+ export function formatCallout(type: string, content: string, lead?: string): string {
84
+ const paragraphs = [lead, content.trim()].filter(Boolean)
85
+ if (paragraphs.length === 0) {
86
+ return ""
87
+ }
88
+
89
+ const quoted = paragraphs
90
+ .join("\n\n")
91
+ .split("\n")
92
+ .map((line) => (line ? `> ${line}` : ">"))
93
+ .join("\n")
94
+ return `> [!${type}]\n${quoted}\n\n`
95
+ }
96
+
97
+ /**
98
+ * Map a DocC aside style to a GitHub-style callout type.
99
+ */
100
+ export function mapAsideStyleToCallout(style: string): string {
101
+ switch (style.toLowerCase()) {
102
+ case "warning":
103
+ return "WARNING"
104
+ case "important":
105
+ return "IMPORTANT"
106
+ case "caution":
107
+ return "CAUTION"
108
+ case "tip":
109
+ return "TIP"
110
+ case "deprecated":
111
+ return "WARNING"
112
+ default:
113
+ return "NOTE"
114
+ }
115
+ }