@@ -37,6 +37,12 @@ class ParsedGroup(TypedDict):
3737 categories : list [ParsedSection ]
3838
3939
40+ class ParsedSponsor (TypedDict ):
41+ name : str
42+ url : str
43+ description : str # inline HTML, properly escaped
44+
45+
4046# --- Slugify ----------------------------------------------------------------
4147
4248_SLUG_NON_ALNUM_RE = re .compile (r"[^a-z0-9\s-]" )
@@ -350,6 +356,80 @@ def flush_group() -> None:
350356 return groups
351357
352358
359+ _SPONSOR_SEP_RE = re .compile (r"^\s*[:\-\u2013\u2014]\s*" )
360+
361+
362+ def _find_link_deep (node : SyntaxTreeNode ) -> SyntaxTreeNode | None :
363+ """Find the first link anywhere in the subtree (including nested in strong/em)."""
364+ for child in node .children :
365+ if child .type == "link" :
366+ return child
367+ found = _find_link_deep (child )
368+ if found :
369+ return found
370+ return None
371+
372+
373+ def _parse_sponsor_item (inline : SyntaxTreeNode ) -> ParsedSponsor | None :
374+ """Parse `**[name](url)**: description` (or `[name](url) - description`)."""
375+ link = _find_link_deep (inline )
376+ if link is None :
377+ return None
378+ name = render_inline_text (link .children )
379+ url = link .attrGet ("href" ) or ""
380+
381+ split_idx = None
382+ for i , child in enumerate (inline .children ):
383+ if child is link or _find_link_deep (child ) is link :
384+ split_idx = i
385+ break
386+ if split_idx is None :
387+ return None
388+ desc_html = render_inline_html (inline .children [split_idx + 1 :])
389+ desc_html = _SPONSOR_SEP_RE .sub ("" , desc_html )
390+ return ParsedSponsor (name = name , url = url , description = desc_html )
391+
392+
393+ def parse_sponsors (text : str ) -> list [ParsedSponsor ]:
394+ """Parse the `# Sponsors` section of README.md into a list of sponsors.
395+
396+ Expects bullets in the form `**[name](url)**: description`.
397+ Returns [] if no Sponsors section exists.
398+ """
399+ md = MarkdownIt ("commonmark" )
400+ tokens = md .parse (text )
401+ root = SyntaxTreeNode (tokens )
402+ children = root .children
403+
404+ start_idx = None
405+ end_idx = len (children )
406+ for i , node in enumerate (children ):
407+ if node .type == "heading" and node .tag == "h1" :
408+ title = _heading_text (node ).strip ().lower ()
409+ if start_idx is None and title == "sponsors" :
410+ start_idx = i + 1
411+ elif start_idx is not None :
412+ end_idx = i
413+ break
414+ if start_idx is None :
415+ return []
416+
417+ sponsors : list [ParsedSponsor ] = []
418+ for node in children [start_idx :end_idx ]:
419+ if node .type != "bullet_list" :
420+ continue
421+ for list_item in node .children :
422+ if list_item .type != "list_item" :
423+ continue
424+ inline = _find_inline (list_item )
425+ if inline is None :
426+ continue
427+ sponsor = _parse_sponsor_item (inline )
428+ if sponsor :
429+ sponsors .append (sponsor )
430+ return sponsors
431+
432+
353433def parse_readme (text : str ) -> list [ParsedGroup ]:
354434 """Parse README.md text into grouped categories.
355435
0 commit comments