From dc9640e8c8a219f1da31f47a36c34725dfa7d47d Mon Sep 17 00:00:00 2001
From: Michael Chow <mc_al_github@fastmail.com>
Date: Tue, 27 Aug 2024 18:49:00 -0400
Subject: [PATCH 1/3] feat!: enable show source links

---
 docs/_quarto.yml                   |  2 ++
 quartodoc/autosummary.py           | 10 ++++++++++
 quartodoc/layout.py                |  2 ++
 quartodoc/renderers/md_renderer.py | 32 +++++++++++++++++++++++++++---
 quartodoc/static/styles.css        | 11 ++++++++++
 5 files changed, 54 insertions(+), 3 deletions(-)

diff --git a/docs/_quarto.yml b/docs/_quarto.yml
index 88cb53b..5306df8 100644
--- a/docs/_quarto.yml
+++ b/docs/_quarto.yml
@@ -90,9 +90,11 @@ quartodoc:
   dir: api
   package: quartodoc
   render_interlinks: true
+  repo_url: https://github.com/machow/quartodoc
   renderer:
     style: markdown
     table_style: description-list
+    show_source_link: true
   sidebar: "api/_sidebar.yml"
   css: "api/_styles-quartodoc.css"
   sections:
diff --git a/quartodoc/autosummary.py b/quartodoc/autosummary.py
index c6bea4e..aa749be 100644
--- a/quartodoc/autosummary.py
+++ b/quartodoc/autosummary.py
@@ -18,6 +18,7 @@
 from . import layout
 from .parsers import get_parser_defaults
 from .renderers import Renderer
+from .repo_info import RepoInfo
 from .validation import fmt_all
 from ._pydantic_compat import ValidationError
 from .pandoc.blocks import Blocks, Header
@@ -441,6 +442,9 @@ class Builder:
     render_interlinks:
         Whether to render interlinks syntax inside documented objects. Note that the
         interlinks filter is required to generate the links in quarto.
+    repo_url:
+        URL for the source repository. This is used to generate links from documentation
+        to source code.
     parser:
         Docstring parser to use. This correspond to different docstring styles,
         and can be one of "google", "sphinx", and "numpy". Defaults to "numpy".
@@ -494,6 +498,7 @@ def __init__(
         dynamic: bool | None = None,
         parser="numpy",
         render_interlinks: bool = False,
+        repo_url: str | None = None,
         _fast_inventory=False,
     ):
         self.layout = self.load_layout(
@@ -507,12 +512,17 @@ def __init__(
         self.sidebar = sidebar
         self.css = css
         self.parser = parser
+        self.repo_url = repo_url
 
         self.renderer = Renderer.from_config(renderer)
         if render_interlinks:
             # this is a top-level option, but lives on the renderer
             # so we just manually set it there for now.
             self.renderer.render_interlinks = render_interlinks
+        if repo_url:
+            # also a top-level option set on renderer
+            print("SETTING REPOINFO")
+            self.renderer.repo_info = RepoInfo.from_link(repo_url)
 
         if out_index is not None:
             self.out_index = out_index
diff --git a/quartodoc/layout.py b/quartodoc/layout.py
index 43cdf8e..12d8fb7 100644
--- a/quartodoc/layout.py
+++ b/quartodoc/layout.py
@@ -267,6 +267,8 @@ class Auto(AutoOptions):
         (Not implemented). A list of members to include.
     exclude:
         (Not implemented). A list of members to exclude.
+    show_source_link:
+        Whether to show a link to item source code.
     dynamic:
         Whether to dynamically load docstring. By default docstrings are loaded
         using static analysis. dynamic may be a string pointing to another object,
diff --git a/quartodoc/renderers/md_renderer.py b/quartodoc/renderers/md_renderer.py
index 583071e..2461faa 100644
--- a/quartodoc/renderers/md_renderer.py
+++ b/quartodoc/renderers/md_renderer.py
@@ -15,6 +15,7 @@
 from quartodoc import layout
 from quartodoc.pandoc.blocks import DefinitionList
 from quartodoc.pandoc.inlines import Span, Strong, Attr, Code, Inlines
+from quartodoc.repo_info import RepoInfo
 
 from .base import Renderer, escape, sanitize, convert_rst_link_to_md
 
@@ -124,19 +125,22 @@ def __init__(
         header_level: int = 1,
         show_signature: bool = True,
         show_signature_annotations: bool = False,
+        show_source_link: bool = False,
         display_name: str = "relative",
         hook_pre=None,
         render_interlinks=False,
-        # table_style="description-list",
         table_style="table",
+        repo_info: "RepoInfo | None" = None,
     ):
         self.header_level = header_level
         self.show_signature = show_signature
         self.show_signature_annotations = show_signature_annotations
+        self.show_source_link = show_source_link
         self.display_name = display_name
         self.hook_pre = hook_pre
         self.render_interlinks = render_interlinks
         self.table_style = table_style
+        self.repo_info = repo_info
 
         self.crnt_header_level = self.header_level
 
@@ -424,8 +428,13 @@ def render(self, el: Union[layout.DocClass, layout.DocModule]):
                         [self.render(x) for x in raw_meths if isinstance(x, layout.Doc)]
                     )
 
-        str_sig = self.signature(el)
-        sig_part = [str_sig] if self.show_signature else []
+        sig_part: list[str] = []
+
+        if self.show_signature:
+            sig_part.append(self.signature(el))
+
+        if self.show_source_link:
+            sig_part.append(self.source_link(el.obj))
 
         with self._increment_header():
             body = self.render(el.obj)
@@ -439,6 +448,7 @@ def render(self, el: Union[layout.DocFunction, layout.DocAttribute]):
         title = self.render_header(el)
 
         str_sig = self.signature(el)
+        str_source = el.obj
         sig_part = [str_sig] if self.show_signature else []
 
         with self._increment_header():
@@ -682,6 +692,22 @@ def render(self, el: ds.DocstringRaise) -> ParamRow:
     def render(self, el):
         raise NotImplementedError(f"{type(el)}")
 
+    # Source links ============================================================
+
+    def source_link(self, el: "dc.Alias | dc.Object"):
+
+        if self.repo_info is not None:
+            fpath = str(el.relative_package_filepath)
+            url = self.repo_info.source_link(fpath)
+
+            return f'<div class="doc-source"><a title="source for {fpath}" href="{url}">[source]</a></div>'
+
+        raise ValueError(
+            "Unable to produce a link to source file without repo info. "
+            "Either set repo_info= in the renderer, or provide a repo_url in the "
+            "`quartodoc:` section of your _quarto.yml."
+        )
+
     # Summarize ===============================================================
     # this method returns a summary description, such as a table summarizing a
     # layout.Section, or a row in the table for layout.Page or layout.DocFunction.
diff --git a/quartodoc/static/styles.css b/quartodoc/static/styles.css
index a029aba..367b207 100644
--- a/quartodoc/static/styles.css
+++ b/quartodoc/static/styles.css
@@ -1,6 +1,17 @@
 /* styles for parameter tables, etc.. ----
 */
 
+.doc-source {
+    width: 100%;
+    text-align: right;
+    font-size: smaller;
+}
+
+.doc-source a {
+    text-decoration: none;
+    float: right;
+}
+
 .doc-section dt code {
     background: none;
 }

From 616e9f29ad18d0ae055a52a6222707b447736197 Mon Sep 17 00:00:00 2001
From: Michael Chow <mc_al_github@fastmail.com>
Date: Tue, 27 Aug 2024 19:22:07 -0400
Subject: [PATCH 2/3] fix: add missing repo_info.py file

---
 quartodoc/repo_info.py | 68 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 68 insertions(+)
 create mode 100644 quartodoc/repo_info.py

diff --git a/quartodoc/repo_info.py b/quartodoc/repo_info.py
new file mode 100644
index 0000000..ad8d9b6
--- /dev/null
+++ b/quartodoc/repo_info.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing_extensions import Self
+
+
+@dataclass
+class GithubLink:
+    host: str
+    owner: str
+    repo: str
+
+    @classmethod
+    def parse(cls, link) -> Self:
+        import re
+
+        # gitlab supports names of the form my/repo/name
+        supports_subgroups = re.search(r"^https?://gitlab\.", link) is not None
+        subgroup_token = "/" if not supports_subgroups else ""
+        rx = (
+            r"^(?P<host>https?://[^/]+)/"
+            r"(?P<owner>[^/]+)/"
+            f"(?P<repo>[^#{subgroup_token}]+)/"
+        )
+        match = re.match(rx, re.sub(r"([^/])$", r"\1/", link))
+        if match is None:
+            raise ValueError(f"Unable to parse link: {link}")
+
+        return GithubLink(**match.groupdict())
+
+
+@dataclass
+class RepoInfo:
+    home: str
+    source: str
+    issue: str
+    user: str
+
+    @classmethod
+    def from_link(cls, link: str | GithubLink, branch: "str | None" = None) -> Self:
+        if isinstance(link, str):
+            gh = GithubLink.parse(link)
+        else:
+            gh = link
+
+        if branch is None:
+            branch = cls.gha_current_branch()
+
+        return cls(
+            home=f"{gh.host}/{gh.owner}/{gh.repo}/",
+            source=f"{gh.host}/{gh.owner}/{gh.repo}/blob/{branch}/",
+            issue=f"{gh.host}/{gh.owner}/{gh.repo}/issues/",
+            user=f"{gh.host}/{gh.owner}/",
+        )
+
+    def source_link(self, path) -> str:
+        return f"{self.source}{path}"
+
+    @classmethod
+    def gha_current_branch(cls) -> str:
+        import os
+
+        ref = os.environ.get("GITHUB_HEAD_REF", os.environ.get("GITHUB_REF_NAME"))
+
+        if ref is not None:
+            return ref
+
+        return "HEAD"

From 8a18dbd86e8f5d5845bdf1bfbd06a732ab79e849 Mon Sep 17 00:00:00 2001
From: Michael Chow <mc_al_github@fastmail.com>
Date: Wed, 11 Sep 2024 11:49:09 -0400
Subject: [PATCH 3/3] tests: basic repo_info tests

---
 quartodoc/tests/test_repo_info.py | 43 +++++++++++++++++++++++++++++++
 1 file changed, 43 insertions(+)
 create mode 100644 quartodoc/tests/test_repo_info.py

diff --git a/quartodoc/tests/test_repo_info.py b/quartodoc/tests/test_repo_info.py
new file mode 100644
index 0000000..deb8a85
--- /dev/null
+++ b/quartodoc/tests/test_repo_info.py
@@ -0,0 +1,43 @@
+import pytest
+
+from quartodoc.repo_info import GithubLink, RepoInfo
+
+
+@pytest.mark.parametrize(
+    "src, host, owner, repo",
+    [
+        (
+            "https://github.com/machow/quartodoc",
+            "https://github.com",
+            "machow",
+            "quartodoc",
+        ),
+        (
+            "https://gitlab.com/machow/quartodoc",
+            "https://gitlab.com",
+            "machow",
+            "quartodoc",
+        ),
+        (
+            "https://gitlab.com/machow/some/pkgs/etc",
+            "https://gitlab.com",
+            "machow",
+            "some/pkgs/etc",
+        ),
+    ],
+)
+def test_github_link_parse(src, host, owner, repo):
+    gh = GithubLink.parse(src)
+    assert gh.host == host
+    assert gh.owner == owner
+    assert gh.repo == repo
+
+
+def test_repo_info_from_link():
+    repo = RepoInfo.from_link(GithubLink("abc", "def", "xyz"), "a_branch")
+    base = "abc/def/xyz"
+
+    assert repo.home == f"{base}/"
+    assert repo.source == f"{base}/blob/a_branch/"
+    assert repo.issue == f"{base}/issues/"
+    assert repo.user == "abc/def/"