Skip to content

Commit 54d3478

Browse files
committed
feat: support simplified interlinking
1 parent 7ca758c commit 54d3478

File tree

12 files changed

+358
-76
lines changed

12 files changed

+358
-76
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/_quarto.yml

Lines changed: 2 additions & 7 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

0 commit comments

Comments
 (0)