diff --git a/quartodoc/builder/blueprint.py b/quartodoc/builder/blueprint.py index e796a894..b95b41e9 100644 --- a/quartodoc/builder/blueprint.py +++ b/quartodoc/builder/blueprint.py @@ -350,13 +350,16 @@ def enter(self, el: Auto): children.append(res) is_flat = el.children == ChoicesChildren.flat - return Doc.from_griffe( - el.name, - obj, - children, - flat=is_flat, - signature_name=el.signature_name, - ) + + # Only pass desc_first if it was explicitly set by the user + kwargs = { + "flat": is_flat, + "signature_name": el.signature_name, + } + if "desc_first" in el._fields_specified: + kwargs["desc_first"] = el.desc_first + + return Doc.from_griffe(el.name, obj, children, **kwargs) def _fetch_members(self, el: Auto, obj: dc.Object | dc.Alias): # Note that this could be a static method, if we passed in the griffe loader diff --git a/quartodoc/layout.py b/quartodoc/layout.py index dcd8db62..94a36b5a 100644 --- a/quartodoc/layout.py +++ b/quartodoc/layout.py @@ -227,6 +227,7 @@ class AutoOptions(_Base): package: Union[str, None, MISSING] = MISSING() member_order: Literal["alphabetical", "source"] = "alphabetical" member_options: Optional["AutoOptions"] = None + desc_first: bool = False # for tracking fields users manually specify # so we can tell them apart from defaults @@ -344,6 +345,7 @@ class Doc(_Docable): obj: Union[dc.Object, dc.Alias] anchor: str signature_name: SignatureOptions = "relative" + desc_first: Optional[bool] = None class Config: arbitrary_types_allowed = True @@ -358,6 +360,7 @@ def from_griffe( anchor: str = None, flat: bool = False, signature_name: str = "relative", + desc_first: Optional[bool] = None, ): if members is None: members = [] @@ -370,6 +373,7 @@ def from_griffe( "obj": obj, "anchor": anchor, "signature_name": signature_name, + "desc_first": desc_first, } if kind == "function": diff --git a/quartodoc/renderers/md_renderer.py b/quartodoc/renderers/md_renderer.py index b36f1bd2..cad70ab4 100644 --- a/quartodoc/renderers/md_renderer.py +++ b/quartodoc/renderers/md_renderer.py @@ -129,6 +129,10 @@ class MdRenderer(Renderer): "full", or "canonical". These options range from just the function name, to its full path relative to its package, to including the package name, to its the its full path relative to its .__module__. + desc_first: bool + Whether to place the description (first paragraph of docstring) after the + object name and before the signature. When True, the order is: title, + description, signature, remaining content. Default is False. Examples -------- @@ -154,6 +158,7 @@ def __init__( render_interlinks=False, # table_style="description-list", table_style="table", + desc_first: bool = False, ): self.header_level = header_level self.show_signature = show_signature @@ -162,6 +167,7 @@ def __init__( self.hook_pre = hook_pre self.render_interlinks = render_interlinks self.table_style = table_style + self.desc_first = desc_first self.crnt_header_level = self.header_level @@ -173,6 +179,59 @@ def _increment_header(self, n=1): finally: self.crnt_header_level -= n + def _extract_description(self, el: Union[dc.Object, dc.Alias]) -> Optional[str]: + if el.docstring is None: + return None + + patched_sections = qast.transform(el.docstring.parsed) + + for section in patched_sections: + title = section.title or section.kind.value + if title == "text": + # Get the raw text value from the section + text = section.value if hasattr(section, 'value') else "" + if text: + # Split on double newline to get first paragraph + paragraphs = text.split('\n\n') + return paragraphs[0].strip() if paragraphs else None + + return None + + def _render_without_first_paragraph(self, el: Union[dc.Object, dc.Alias]) -> str: + if el.docstring is None: + return "" + + str_body = [] + patched_sections = qast.transform(el.docstring.parsed) + first_text_seen = False + + for section in patched_sections: + title = section.title or section.kind.value + + # Handle the first text section specially + if title == "text" and not first_text_seen: + first_text_seen = True + # Get remaining paragraphs after the first one + text = section.value if hasattr(section, 'value') else "" + if text: + paragraphs = text.split('\n\n') + if len(paragraphs) > 1: + # Join remaining paragraphs and add them + remaining = '\n\n'.join(paragraphs[1:]).strip() + if remaining: + str_body.append(remaining) + continue + + body: str = self.render(section) + + if title != "text": + header = self.render_header(section) + str_body.append("\n\n".join([header, body])) + else: + str_body.append(body) + + return "\n\n".join(str_body) + def _fetch_object_dispname(self, el: "dc.Alias | dc.Object"): # TODO: copied from Builder, should move into util function if self.display_name in {"name", "short"}: @@ -453,12 +512,36 @@ def render(self, el: Union[layout.DocClass, layout.DocModule]): str_sig = self.signature(el) sig_part = [str_sig] if self.show_signature else [] - with self._increment_header(): - body = self.render(el.obj) + # Check for desc_first on the element first, then fall back to renderer default + desc_first = getattr(el, 'desc_first', None) + if desc_first is None: + desc_first = self.desc_first + + if desc_first: + # Extract first paragraph as description + desc = self._extract_description(el.obj) + + with self._increment_header(): + # Render body without first paragraph + body = self._render_without_first_paragraph(el.obj) + + # Reorder to get title, description, signature, rest of body, members + if desc: + # Wrap description in a div with inline styles + desc_wrapped = f'::: {{.lead style="font-style: italic; margin-top: -10px;"}}\n{desc}\n:::' + # If body is not empty, include it; otherwise, don't include it + parts = ([title, desc_wrapped, *sig_part, body, *attr_docs, *class_docs, *meth_docs] if body + else [title, desc_wrapped, *sig_part, *attr_docs, *class_docs, *meth_docs]) + else: + # Case with no description extracted + parts = [title, *sig_part, body, *attr_docs, *class_docs, *meth_docs] + else: + with self._increment_header(): + body = self.render(el.obj) + + parts = [title, *sig_part, body, *attr_docs, *class_docs, *meth_docs] - return "\n\n".join( - [title, *sig_part, body, *attr_docs, *class_docs, *meth_docs] - ) + return "\n\n".join(parts) @dispatch def render(self, el: Union[layout.DocFunction, layout.DocAttribute]): @@ -467,10 +550,33 @@ def render(self, el: Union[layout.DocFunction, layout.DocAttribute]): str_sig = self.signature(el) sig_part = [str_sig] if self.show_signature else [] - with self._increment_header(): - body = self.render(el.obj) + # Check for desc_first on the element first, then fall back to renderer default + desc_first = getattr(el, 'desc_first', None) + if desc_first is None: + desc_first = self.desc_first + + if desc_first: + # Extract first paragraph as description + desc = self._extract_description(el.obj) + + with self._increment_header(): + # Render body without the first paragraph + body = self._render_without_first_paragraph(el.obj) + + # Reorder: title, description, signature, rest of body + if desc: + # Wrap description in a div with inline styles + desc_wrapped = f'::: {{.lead style="font-size: 1rem; font-style: italic; margin-top: -10px; line-height: 1;"}}\n{desc}\n:::' + parts = [title, desc_wrapped, *sig_part, body] if body else [title, desc_wrapped, *sig_part] + else: + parts = [title, *sig_part, body] + else: + with self._increment_header(): + body = self.render(el.obj) + + parts = [title, *sig_part, body] - return "\n\n".join([title, *sig_part, body]) + return "\n\n".join(parts) # render griffe objects =================================================== diff --git a/quartodoc/tests/test_renderers.py b/quartodoc/tests/test_renderers.py index ee241f23..35c5e373 100644 --- a/quartodoc/tests/test_renderers.py +++ b/quartodoc/tests/test_renderers.py @@ -247,3 +247,71 @@ def test_render_numpydoc_section_return(snapshot, doc): assert snapshot == indented_sections( Code=full_doc, Default=res_default, List=res_list ) + + +# desc_first tests ------------------------------------------------------------ + + +def test_render_desc_first_function(): + auto = Auto(name="a_func", package="quartodoc.tests.example", desc_first=True) + bp = blueprint(auto) + + renderer = MdRenderer() + result = renderer.render(bp) + + # The result should have: title, then description, then signature, then rest + desc_pos = result.find("A function") + title_pos = result.find("# a_func") + + assert desc_pos != -1 + assert title_pos != -1 + assert title_pos < desc_pos + + +def test_render_desc_first_false(): + auto = Auto(name="a_func", package="quartodoc.tests.example", desc_first=False) + bp = blueprint(auto) + + renderer = MdRenderer() + result = renderer.render(bp) + + # The result should have: title, signature, then description + desc_pos = result.find("A function") + title_pos = result.find("# a_func") + + assert desc_pos != -1 + assert title_pos != -1 + assert title_pos < desc_pos + + +def test_render_desc_first_class(): + auto = Auto(name="AClass", package="quartodoc.tests.example", desc_first=True) + bp = blueprint(auto) + + renderer = MdRenderer() + result = renderer.render(bp) + + # Check that "# AClass" (title) appears before "A class" (description) + desc_pos = result.find("A class") + title_pos = result.find("# AClass") + + assert desc_pos != -1 + assert title_pos != -1 + assert title_pos < desc_pos + + +def test_render_desc_first_renderer_default(): + auto = Auto(name="a_func", package="quartodoc.tests.example") + bp = blueprint(auto) + + # Renderer with desc_first=True as default + renderer = MdRenderer(desc_first=True) + result = renderer.render(bp) + + # Check that title comes before description + desc_pos = result.find("A function") + title_pos = result.find("# a_func") + + assert desc_pos != -1 + assert title_pos != -1 + assert title_pos < desc_pos