Skip to content

Commit 801a181

Browse files
authored
Merge pull request #26 from machow/feat-interlinks
feat: support simplified config, fully specified interlinking syntax
2 parents 7ca758c + 689e552 commit 801a181

File tree

14 files changed

+418
-85
lines changed

14 files changed

+418
-85
lines changed

Makefile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ README.md: README.qmd
22
quarto render $<
33

44
examples/%/_site: examples/%/_quarto.yml
5-
python -m quartodoc $<
5+
python -m quartodoc build $<
66
quarto render $(dir $<)
77

88
docs/examples/%: examples/%/_site
@@ -13,7 +13,8 @@ docs-build-examples: docs/examples/single-page docs/examples/pkgdown
1313

1414
docs-build: docs-build-examples
1515
cd docs && quarto add --no-prompt ..
16-
cd docs && python -m quartodoc
16+
cd docs && python -m quartodoc build
17+
cd docs && python -m quartodoc interlinks
1718
quarto render docs
1819

1920
requirements-dev.txt:

_extensions/interlinks/interlinks.py

Lines changed: 133 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,119 @@
1+
import itertools
12
import json
23
import panflute as pf
34

5+
from quartodoc.inventory import Ref, RefSyntaxError
6+
from pathlib import Path
47
from plum import dispatch
58

69

7-
inventory = {}
10+
# Hold all inventory items in a singleton -------------------------------------
11+
12+
# TODO: make entries into dataclass
13+
# has fields: name, domain, role, priority, invname, full_uri
14+
15+
16+
class InvLookupError(Exception):
17+
pass
18+
19+
20+
class Inventories:
21+
def __init__(self):
22+
self.registry = {}
23+
24+
def items(self):
25+
return itertools.chain(*self.registry.values())
26+
27+
def load_inventory(self, inventory, url, invname):
28+
all_items = []
29+
for item in inventory["items"]:
30+
# TODO: what are the rules for inventories with overlapping names?
31+
# it seems like this is where priority and using source name as an
32+
# optional prefix in references is useful (e.g. siuba:a.b.c).
33+
full_uri = url + item["uri"].replace("$", item["name"])
34+
enh_item = {**item, "invname": invname, "full_uri": full_uri}
35+
all_items.append(enh_item)
36+
37+
self.registry[invname] = all_items
38+
39+
def lookup_reference(self, ref: Ref):
40+
# return global_inventory[ref]
41+
42+
crnt_items = self.items()
43+
for field in ["name", "role", "domain", "invname"]:
44+
if field == "name":
45+
# target may have ~ option in front, so we strip it off
46+
field_value = ref.target.lstrip("~")
47+
else:
48+
field_value = getattr(ref, field)
49+
50+
crnt_items = _filter_by_field(crnt_items, field, field_value)
51+
52+
results = list(crnt_items)
53+
if not results:
54+
raise InvLookupError(
55+
f"Cross reference not found in an inventory file: `{ref}`"
56+
)
57+
58+
if len(results) > 1:
59+
raise InvLookupError(
60+
f"Cross reference matches multiple entries.\n"
61+
f"Matching entries: {len(results)}\n"
62+
f"Reference: {ref}\n"
63+
f"Top 2 matches: \n * {results[0]}\n * {results[1]}"
64+
)
65+
66+
return results[0]
67+
68+
69+
global_inventory = Inventories()
70+
71+
72+
# Utility functions -----------------------------------------------------------
873

974

1075
class ConfigError(Exception):
1176
pass
1277

1378

14-
def load_mock_inventory(items: "dict[str, str]"):
15-
for k, v in items.items():
16-
inventory[k] = v
79+
def get_path_to_root():
80+
# I have no idea how to get the documentation root,
81+
# except to get the path the extension script, which
82+
# lives in <root>/_extensions/interlinks, and work back
83+
return Path(__file__).parent.parent.parent
84+
85+
86+
def load_inventories(interlinks: dict):
87+
p_root = get_path_to_root()
88+
89+
sources = interlinks["sources"]
90+
cache = interlinks.get("cache", "_inv")
91+
92+
# load this sites inventory ----
93+
site_inv = interlinks.get("site_inv", "objects.json")
1794

95+
json_data = json.load(open(p_root / site_inv))
96+
global_inventory.load_inventory(json_data, url="/", invname="")
1897

19-
def ref_to_anchor(ref: str, text: "str | pf.ListContainer | None"):
98+
# load other inventories ----
99+
for doc_name, cfg in sources.items():
100+
101+
fname = doc_name + "_objects.json"
102+
inv_path = p_root / Path(cache) / fname
103+
104+
json_data = json.load(open(inv_path))
105+
106+
global_inventory.load_inventory(json_data, url=cfg["url"], invname=doc_name)
107+
108+
109+
def _filter_by_field(items, field_name: str, value: "str | None" = None):
110+
if value is None:
111+
return items
112+
113+
return (item for item in items if item[field_name] == value)
114+
115+
116+
def ref_to_anchor(raw: str, text: "str | pf.ListContainer | None"):
20117
"""Return a Link element based on ref in interlink format
21118
22119
Parameters
@@ -38,17 +135,17 @@ def ref_to_anchor(ref: str, text: "str | pf.ListContainer | None"):
38135
Link(Str(partial); url='https://example.org/functools.partial.html')
39136
"""
40137
# TODO: for now we just mutate el
41-
is_shortened = ref.startswith("~")
42-
43-
stripped = ref.lstrip("~")
44138

45139
try:
46-
entry = inventory[stripped]
47-
dst_url = entry["full_uri"]
48-
except KeyError:
49-
raise KeyError(f"Cross reference not found in an inventory file: {stripped}")
140+
ref = Ref.from_string(raw)
141+
except RefSyntaxError as e:
142+
pf.debug("WARNING: ", str(e))
143+
144+
is_shortened = ref.target.startswith("~")
145+
146+
entry = global_inventory.lookup_reference(ref)
147+
dst_url = entry["full_uri"]
50148

51-
pf.debug(f"TEXT IS: {text}")
52149
if not text:
53150
name = entry["name"] if entry["dispname"] == "-" else entry["dispname"]
54151
if is_shortened:
@@ -79,8 +176,6 @@ def parse_rst_style_ref(full_text):
79176

80177
import re
81178

82-
# pf.debug(full_text)
83-
84179
m = re.match(r"(?P<text>.+?)\<(?P<ref>[a-zA-Z\.\-: _]+)\>", full_text)
85180
if m is None:
86181
# TODO: print a warning or something
@@ -105,7 +200,7 @@ def visit(el: pf.MetaList, doc):
105200
meta = doc.get_metadata()
106201

107202
try:
108-
sources = meta["interlinks"]["sources"]
203+
interlinks = meta["interlinks"]
109204
except KeyError:
110205
raise ConfigError(
111206
"No interlinks.sources field detected in your metadata."
@@ -114,16 +209,8 @@ def visit(el: pf.MetaList, doc):
114209
"\n sources:"
115210
"\n - <source_name>: {url: ..., inv: ..., fallback: ... }"
116211
)
117-
for doc_name, cfg in sources.items():
118-
json_data = json.load(open(cfg["fallback"]))
119212

120-
for item in json_data["items"]:
121-
# TODO: what are the rules for inventories with overlapping names?
122-
# it seems like this is where priority and using source name as an
123-
# optional prefix in references is useful (e.g. siuba:a.b.c).
124-
full_uri = cfg["url"] + item["uri"].replace("$", item["name"])
125-
enh_item = {**item, "full_uri": full_uri}
126-
inventory[item["name"]] = enh_item
213+
load_inventories(interlinks)
127214

128215
return el
129216

@@ -133,29 +220,32 @@ def visit(el: pf.Doc, doc):
133220
return el
134221

135222

136-
@dispatch
137-
def visit(el: pf.Plain, doc):
138-
cont = el.content
139-
if len(cont) == 2 and cont[0] == pf.Str(":ref:") and isinstance(cont[1], pf.Code):
140-
_, code = el.content
141-
142-
ref, title = parse_rst_style_ref(code.text)
143-
144-
return pf.Plain(ref_to_anchor(ref, title))
145-
146-
return el
223+
# TODO: the syntax :ref:`target` is not trivial to implement. The pandoc AST
224+
# often embeds it in a list of Plain with other elements. Currently, we only
225+
# support the syntax inside of links.
226+
#
227+
# @dispatch
228+
# def visit(el: pf.Plain, doc):
229+
# cont = el.content
230+
# if len(cont) == 2 and cont[0] == pf.Str(":ref:") and isinstance(cont[1], pf.Code):
231+
# _, code = el.content
232+
#
233+
# ref, title = parse_rst_style_ref(code.text)
234+
#
235+
# return pf.Plain(ref_to_anchor(ref, title))
236+
#
237+
# return el
147238

148239

149240
@dispatch
150241
def visit(el: pf.Link, doc):
151-
if el.url.startswith("%60") and el.url.endswith("%60"):
152-
url = el.url[3:-3]
153-
242+
url = el.url
243+
if (url.startswith("%60") or url.startswith(":")) and url.endswith("%60"):
154244
# Get URL ----
155-
156-
# TODO: url can be form external+invname:domain:reftype:target
157-
# for now, assume it's simply <target>. e.g. siuba.dply.verbs.mutate
158-
return ref_to_anchor(url, el.content)
245+
try:
246+
return ref_to_anchor(url.replace("%60", "`"), el.content)
247+
except InvLookupError as e:
248+
pf.debug("WARNING: " + str(e))
159249

160250
return el
161251

docs/.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,13 @@
22
_site
33
reference
44

5+
# examples
56
examples/pkgdown
67
examples/single-page
8+
9+
# reference folder
10+
api
11+
12+
# interlinks
13+
_inv
14+
objects.json

docs/_quarto.yml

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,16 @@ project:
22
type: website
33
output-dir: _build
44
resources:
5-
- objects.json
65
- examples/single-page
76
- examples/pkgdown
8-
#pre-render: "python -m quartodoc _quarto.yml"
97

108
filters:
119
- "interlinks"
1210

1311
interlinks:
1412
sources:
15-
quartodoc:
16-
# TODO: add note that this works as the site root
17-
url: "/"
18-
inv: null
19-
fallback: objects.json
13+
python:
14+
url: https://docs.python.org/3/
2015

2116
quartodoc:
2217
style: pkgdown
@@ -79,12 +74,12 @@ website:
7974
contents:
8075
- get-started/basic-docs.qmd
8176
- get-started/crossrefs.qmd
77+
- get-started/interlinks.qmd
8278
- get-started/sidebar.qmd
8379
- section: "Advanced"
8480
contents:
8581
- get-started/docstrings.qmd
8682
- get-started/renderers.qmd
87-
- get-started/interlinks.qmd
8883
- section: "Extra Topics"
8984
contents:
9085
- get-started/architecture.qmd

docs/get-started/crossrefs.qmd

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,46 @@ jupyter:
77
name: python3
88
---
99

10-
Coming soon!
11-
1210
## Linking by path
1311

14-
You can link to function docs by using the path to the generated documentation file:
15-
16-
``[get_object](/reference/get_object.qmd)``
12+
You can use [quarto's markdown linking syntax](https://quarto.org/docs/authoring/markdown-basics.html#links-images)
13+
to link to function docs, by using the path to the generated documentation file.
1714

15+
Here are some examples:
1816

19-
* /reference/
17+
| code | result |
18+
| ---- | ------ |
19+
| ``[get_object](/reference/get_object.qmd)`` | [get_object](/reference/get_object.qmd) |
20+
| ``[link text](/reference/MdRenderer.qmd)`` | [link text](/reference/MdRenderer.qmd) |
2021

2122

2223
## Linking by function name
2324

25+
Use quartodoc's [interlinking filter](./interlinks.qmd) to link to functions using only their names:
26+
27+
| code | result |
28+
| ---- | ------ |
29+
| ``[](`quartodoc.get_object`)`` | [](`quartodoc.get_object`) |
30+
31+
Notice that the link above puts the function name in backticks, rather than using
32+
the path to its documentation: `` `quartodoc.get_object` ``.
33+
34+
You can also use this approach to link to other documentation sites.
35+
For example, including links to quartodoc, or https://docs.python.org/3 using function names.
36+
37+
See the [interlinks documentation](./interlinks.qmd) for set up and usage.
38+
2439

2540
## The "See Also" section
2641

42+
A major goal of quartodoc is to automatically turn text in the "See Also" section
43+
of docstrings into links.
44+
45+
See [this issue](https://github.com/machow/quartodoc/issues/21) for more details
46+
on parsing See Also sections, and [this issue](https://github.com/machow/quartodoc/issues/22)
47+
on turning type annotations into links.
2748

49+
## Type annotations in docstrings
2850

51+
This is planned, but currently unimplemented. See [this issue](https://github.com/machow/quartodoc/issues/22)
52+
on turning type annotations into links.

0 commit comments

Comments
 (0)