1+ import operator
12import re
23
4+ import requests
35import streamlit as st
46
57from app .utils .github_utils import (
810 fetch_pull_request_payload ,
911 fetch_repo_file_text_at_ref ,
1012 get_all_github_prs ,
13+ get_headers ,
1114)
1215
1316st .set_page_config (page_title = "Spec renderer" , page_icon = "🔧" )
@@ -28,6 +31,47 @@ def filter_spec_prs(prs: list[dict]) -> list[dict]:
2831 return spec_prs
2932
3033
34+ @st .cache_data (ttl = 600 )
35+ def fetch_merged_specs () -> list [dict ]:
36+ """Fetch list of merged spec folders from the specs directory on develop branch."""
37+ url = "https://api.github.com/repos/streamlit/streamlit/contents/specs"
38+ try :
39+ response = requests .get (url , headers = get_headers (), params = {"ref" : "develop" }, timeout = 30 )
40+ if response .status_code != 200 :
41+ return []
42+ contents = response .json ()
43+ except requests .RequestException :
44+ return []
45+
46+ specs = []
47+ date_pattern = re .compile (r"^(\d{4}-\d{2}-\d{2})-(.+)$" )
48+
49+ for item in contents :
50+ if item .get ("type" ) != "dir" :
51+ continue
52+ name = item .get ("name" , "" )
53+ match = date_pattern .match (name )
54+ if match :
55+ date_str = match .group (1 )
56+ feature_name = match .group (2 ).replace ("-" , " " ).title ()
57+ specs .append (
58+ {
59+ "folder" : name ,
60+ "date" : date_str ,
61+ "title" : feature_name ,
62+ "path" : f"specs/{ name } /product-spec.md" ,
63+ }
64+ )
65+
66+ specs .sort (key = operator .itemgetter ("date" ), reverse = True )
67+ return specs
68+
69+
70+ def fetch_merged_spec_content (spec_path : str ) -> tuple [str | None , str | None ]:
71+ """Fetch the content of a merged spec file from the develop branch."""
72+ return fetch_repo_file_text_at_ref ("streamlit/streamlit" , spec_path , "develop" )
73+
74+
3175@st .cache_data (ttl = 300 )
3276def fetch_pr_files (pr_number : int ) -> tuple [list [dict ], str | None ]:
3377 """Fetch files changed in a PR."""
@@ -141,11 +185,13 @@ def fetch_issue_details(issue_number: int) -> tuple[dict | None, str | None]:
141185
142186def replace_issue_references_with_previews (markdown_content : str ) -> str :
143187 """Replace issue references with styled previews."""
144- # Pattern for standalone issue numbers like #1234
145- standalone_pattern = r"(?<!\[)#(\d+)(?!\])"
146-
147188 # Pattern for issue links like [#12331](https://github.com/streamlit/streamlit/issues/12331)
148- link_pattern = r"\[#(\d+)\]\(https://github\.com/streamlit/streamlit/issues/\d+\)"
189+ # Also handles optional trailing content in URL (query params, anchors)
190+ link_pattern = r"\[#(\d+)\]\(https://github\.com/streamlit/streamlit/issues/\d+[^)]*\)"
191+
192+ # Pattern for standalone issue numbers like #1234
193+ # Must not be preceded by '[' and must not be followed by ']' or '('
194+ standalone_pattern = r"(?<!\[)#(\d+)(?![\]\(])"
149195
150196 def create_issue_preview (issue_number : int ) -> str :
151197 """Create a styled issue preview using only markdown features."""
@@ -183,11 +229,11 @@ def replace_issue_link(match: re.Match) -> str:
183229 issue_number = int (match .group (1 ))
184230 return create_issue_preview (issue_number )
185231
186- # Replace standalone issue references first
187- updated_content = re .sub (standalone_pattern , replace_standalone_issue , markdown_content )
232+ # Replace issue links FIRST (before standalone), so we don't corrupt the link syntax
233+ updated_content = re .sub (link_pattern , replace_issue_link , markdown_content )
188234
189- # Then replace issue links
190- updated_content = re .sub (link_pattern , replace_issue_link , updated_content )
235+ # Then replace standalone issue references
236+ updated_content = re .sub (standalone_pattern , replace_standalone_issue , updated_content )
191237
192238 return updated_content
193239
@@ -231,20 +277,33 @@ def extract_frontmatter(markdown_content: str) -> tuple[dict | None, str]:
231277 return frontmatter , content_without_frontmatter
232278
233279
280+ def is_likely_github_username (name : str ) -> bool :
281+ """Check if a string looks like a GitHub username."""
282+ if not name or len (name ) > 39 :
283+ return False
284+ # GitHub usernames: alphanumeric and hyphens, can't start/end with hyphen
285+ github_username_pattern = r"^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$"
286+ return bool (re .match (github_username_pattern , name ))
287+
288+
234289def render_frontmatter_caption (frontmatter : dict ) -> None :
235290 """Render frontmatter as caption."""
236291 if not frontmatter :
237292 return
238293
239294 info_parts = []
240295
241- # Add author (with link if it's a GitHub mention)
296+ # Add author (with link if it's a GitHub mention or looks like a username )
242297 if "author" in frontmatter :
243298 author = frontmatter ["author" ].strip ("\" '" ) # Remove surrounding quotes
244299 if author .startswith ("@" ):
245300 username = author [1 :] # Remove @
246301 author_link = f"[@{ username } ](https://github.com/{ username } )"
247302 info_parts .append (f"By { author_link } " )
303+ elif is_likely_github_username (author ):
304+ # Treat as GitHub username even without @
305+ author_link = f"[@{ author } ](https://github.com/{ author } )"
306+ info_parts .append (f"By { author_link } " )
248307 else :
249308 info_parts .append (f"By { author } " )
250309
@@ -291,18 +350,73 @@ def extract_title_and_content(markdown_content: str) -> tuple[str | None, str]:
291350 return title , remaining_content
292351
293352
294- def main () -> None :
295- title_row = st .container (horizontal = True , horizontal_alignment = "distribute" , vertical_alignment = "center" )
296- with title_row :
297- st .title ("🔧 Spec renderer" )
298- if st .button (":material/refresh: Refresh Data" , type = "tertiary" ):
299- get_all_github_prs .clear ()
300- fetch_pr_files .clear ()
301- fetch_pull_request_payload .clear ()
302- fetch_repo_file_text_at_ref .clear ()
303- fetch_issue_payload .clear ()
304- st .markdown ("Read product specs from the Streamlit repo. So far only supports PRs, not merged specs." )
353+ def replace_local_images_with_github_urls_for_merged_spec (markdown_content : str , spec_file_path : str ) -> str :
354+ """Replace local image references with GitHub raw URLs for merged specs on develop branch."""
355+ # Get the directory of the spec file to resolve relative paths
356+ spec_dir = "/" .join (spec_file_path .split ("/" )[:- 1 ]) # Remove filename, keep directory
357+
358+ # Pattern to match markdown images with local paths
359+ image_pattern = r"!\[([^\]]*)\]\((?!https?://)([^)]+)\)"
360+
361+ def replace_image (match : re .Match ) -> str :
362+ alt_text = match .group (1 )
363+ image_path = match .group (2 )
364+
365+ # Handle relative paths
366+ if image_path .startswith ("./" ):
367+ clean_path = image_path [2 :]
368+ full_path = f"{ spec_dir } /{ clean_path } " if spec_dir else clean_path
369+ elif image_path .startswith ("../" ):
370+ dir_parts = spec_dir .split ("/" ) if spec_dir else []
371+ path_parts = image_path .split ("/" )
372+ up_count = sum (1 for part in path_parts if part == ".." )
373+ remaining_parts = [part for part in path_parts if part != ".." ]
374+ final_dir_parts = dir_parts [:- up_count ] if up_count <= len (dir_parts ) else []
375+ full_path = "/" .join (final_dir_parts + remaining_parts )
376+ else :
377+ full_path = f"{ spec_dir } /{ image_path } " if spec_dir else image_path
378+
379+ # Create GitHub raw URL for develop branch
380+ github_url = f"https://raw.githubusercontent.com/streamlit/streamlit/develop/{ full_path } "
381+ return f""
305382
383+ return re .sub (image_pattern , replace_image , markdown_content )
384+
385+
386+ def render_spec_content (
387+ markdown_content : str ,
388+ spec_file_path : str ,
389+ pr_number : int | None = None ,
390+ ) -> None :
391+ """Render spec content with all transformations applied."""
392+ # Extract frontmatter
393+ frontmatter , content_without_frontmatter = extract_frontmatter (markdown_content )
394+
395+ # Extract title and remaining content
396+ title , remaining_content = extract_title_and_content (content_without_frontmatter )
397+
398+ # Render title if found
399+ if title :
400+ st .markdown (f"# { title } " )
401+
402+ # Render frontmatter as caption below title
403+ if frontmatter :
404+ render_frontmatter_caption (frontmatter )
405+
406+ # Replace local image references with GitHub URLs
407+ if pr_number is not None :
408+ processed_content = replace_local_images_with_github_urls (remaining_content , pr_number , spec_file_path )
409+ else :
410+ processed_content = replace_local_images_with_github_urls_for_merged_spec (remaining_content , spec_file_path )
411+
412+ # Replace issue references with styled previews
413+ processed_content = replace_issue_references_with_previews (processed_content )
414+
415+ st .markdown (processed_content )
416+
417+
418+ def render_open_spec_prs () -> None :
419+ """Render the open spec PRs tab."""
306420 # Get query parameters
307421 query_params = st .query_params
308422 pr_param = query_params .get ("pr" , None )
@@ -393,29 +507,88 @@ def main() -> None:
393507 return
394508
395509 if markdown_content :
396- # Extract frontmatter
397- frontmatter , content_without_frontmatter = extract_frontmatter (markdown_content )
510+ render_spec_content (markdown_content , spec_file , pr_number )
511+ else :
512+ st .error ("Failed to fetch markdown content." )
513+
398514
399- # Extract title and remaining content
400- title , remaining_content = extract_title_and_content (content_without_frontmatter )
515+ def render_merged_specs () -> None :
516+ """Render the merged specs tab."""
517+ query_params = st .query_params
518+ spec_param = query_params .get ("spec" , None )
401519
402- # Render title if found
403- if title :
404- st .markdown (f"# { title } " )
520+ merged_specs = fetch_merged_specs ()
521+
522+ if not merged_specs :
523+ st .info ("No merged specs found in the repository." )
524+ return
405525
406- # Render frontmatter as caption below title
407- if frontmatter :
408- render_frontmatter_caption (frontmatter )
526+ # Create selectbox options
527+ spec_options = {}
528+ for spec in merged_specs :
529+ label = f"{ spec ['date' ]} - { spec ['title' ]} "
530+ spec_options [label ] = spec
409531
410- # Replace local image references with GitHub URLs
411- processed_content = replace_local_images_with_github_urls (remaining_content , pr_number , spec_file )
532+ # Determine default index based on query parameter
533+ default_index = None
534+ if spec_param :
535+ for i , spec in enumerate (merged_specs ):
536+ if spec ["folder" ] == spec_param :
537+ default_index = i
538+ break
412539
413- # Replace issue references with styled previews
414- processed_content = replace_issue_references_with_previews (processed_content )
540+ selected_option = st .selectbox (
541+ "Select a merged spec:" ,
542+ options = list (spec_options .keys ()),
543+ index = default_index ,
544+ key = "merged_spec_selector" ,
545+ )
415546
416- st .markdown (processed_content )
547+ if selected_option :
548+ selected_spec = spec_options [selected_option ]
549+
550+ # Update query parameter
551+ if (
552+ "last_selected_spec" not in st .session_state
553+ or st .session_state .last_selected_spec != selected_spec ["folder" ]
554+ ):
555+ st .query_params ["spec" ] = selected_spec ["folder" ]
556+ st .session_state .last_selected_spec = selected_spec ["folder" ]
557+
558+ # Fetch and render spec content
559+ with st .spinner ("Fetching spec content..." ):
560+ markdown_content , error = fetch_merged_spec_content (selected_spec ["path" ])
561+
562+ if error :
563+ st .error (error )
564+ return
565+
566+ if markdown_content :
567+ render_spec_content (markdown_content , selected_spec ["path" ])
417568 else :
418- st .error ("Failed to fetch markdown content." )
569+ st .warning (f"Could not find `product-spec.md` in `{ selected_spec ['folder' ]} `." )
570+
571+
572+ def main () -> None :
573+ title_row = st .container (horizontal = True , horizontal_alignment = "distribute" , vertical_alignment = "center" )
574+ with title_row :
575+ st .title ("🔧 Spec renderer" )
576+ if st .button (":material/refresh: Refresh Data" , type = "tertiary" ):
577+ get_all_github_prs .clear ()
578+ fetch_pr_files .clear ()
579+ fetch_pull_request_payload .clear ()
580+ fetch_repo_file_text_at_ref .clear ()
581+ fetch_issue_payload .clear ()
582+ fetch_merged_specs .clear ()
583+ st .markdown ("Read product specs from the Streamlit repo." )
584+
585+ tab_open , tab_merged = st .tabs (["Open PRs" , "Merged Specs" ])
586+
587+ with tab_open :
588+ render_open_spec_prs ()
589+
590+ with tab_merged :
591+ render_merged_specs ()
419592
420593
421594main ()
0 commit comments