cpdocs 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cpdocs/model.py ADDED
@@ -0,0 +1,228 @@
1
+ # SPDX-FileCopyrightText: 2026 cpdocs developers
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Language-neutral API graph used by cpdocs' reference renderer."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Iterable
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class SourceLocation:
14
+ path: str
15
+ line: int | None = None
16
+ column: int | None = None
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ApiParameter:
21
+ name: str
22
+ type: str = ""
23
+ default: str | None = None
24
+ kind: str = ""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ApiSignature:
29
+ parameters: tuple[ApiParameter, ...] = ()
30
+ returns: str = ""
31
+ qualifiers: tuple[str, ...] = ()
32
+ spelling: str = ""
33
+ template: str = ""
34
+ # Documentation and location of this overload; not part of its identity.
35
+ documentation: str = field(default="", compare=False)
36
+ source: SourceLocation | None = field(default=None, compare=False)
37
+
38
+ @property
39
+ def compact(self) -> str:
40
+ values = [parameter.type or parameter.name or "?" for parameter in self.parameters]
41
+ return f"({', '.join(values)})"
42
+
43
+
44
+ @dataclass
45
+ class ApiEntity:
46
+ id: str
47
+ language: str
48
+ kind: str
49
+ name: str
50
+ qualified_name: str
51
+ parent: str | None = None
52
+ signatures: list[ApiSignature] = field(default_factory=list)
53
+ documentation: str = ""
54
+ source: SourceLocation | None = None
55
+ properties: list[str] = field(default_factory=list)
56
+ variants: list[str] = field(default_factory=list)
57
+ children: list[str] = field(default_factory=list)
58
+ bases: list[str] = field(default_factory=list)
59
+ aliases: list[str] = field(default_factory=list)
60
+ related: list[str] = field(default_factory=list)
61
+ template: str = ""
62
+ declaration: str = ""
63
+ # Variants of each signature, parallel to ``signatures``; empty when unknown.
64
+ signature_variants: list[list[str]] = field(default_factory=list)
65
+
66
+ @property
67
+ def navigation_label(self) -> str:
68
+ # The outline is deliberately name-only. Detailed/overload signatures belong to the
69
+ # entity page and its local "On this page" navigation, not to the global API tree.
70
+ return self.name
71
+
72
+ @property
73
+ def member_label(self) -> str:
74
+ if self.kind in {"function", "method", "constructor"}:
75
+ return f"{self.name}()"
76
+ return self.name
77
+
78
+
79
+ @dataclass
80
+ class ApiGraph:
81
+ project: str
82
+ version: str
83
+ language: str
84
+ entities: dict[str, ApiEntity] = field(default_factory=dict)
85
+ variants: dict[str, dict[str, object]] = field(default_factory=dict)
86
+ sources: dict[str, str] = field(default_factory=dict)
87
+ metadata: dict[str, object] = field(default_factory=dict)
88
+
89
+ def add(self, entity: ApiEntity) -> None:
90
+ existing = self.entities.get(entity.id)
91
+ if existing is None:
92
+ self.entities[entity.id] = entity
93
+ return
94
+
95
+ while len(existing.signature_variants) < len(existing.signatures):
96
+ existing.signature_variants.append([])
97
+ for index, signature in enumerate(entity.signatures):
98
+ incoming = (
99
+ entity.signature_variants[index] if index < len(entity.signature_variants) else []
100
+ )
101
+ if signature not in existing.signatures:
102
+ existing.signatures.append(signature)
103
+ existing.signature_variants.append(list(incoming))
104
+ continue
105
+ known = existing.signature_variants[existing.signatures.index(signature)]
106
+ known.extend(value for value in incoming if value not in known)
107
+ for value in entity.properties:
108
+ if value not in existing.properties:
109
+ existing.properties.append(value)
110
+ for value in entity.variants:
111
+ if value not in existing.variants:
112
+ existing.variants.append(value)
113
+ for value in entity.children:
114
+ if value not in existing.children:
115
+ existing.children.append(value)
116
+ for value in entity.bases:
117
+ if value not in existing.bases:
118
+ existing.bases.append(value)
119
+ for value in entity.aliases:
120
+ if value not in existing.aliases:
121
+ existing.aliases.append(value)
122
+ for value in entity.related:
123
+ if value not in existing.related:
124
+ existing.related.append(value)
125
+ if not existing.documentation and entity.documentation:
126
+ existing.documentation = entity.documentation
127
+ if existing.source is None and entity.source is not None:
128
+ existing.source = entity.source
129
+ if not existing.template and entity.template:
130
+ existing.template = entity.template
131
+ if not existing.declaration and entity.declaration:
132
+ existing.declaration = entity.declaration
133
+
134
+ def rebuild_children(self) -> None:
135
+ for entity in self.entities.values():
136
+ entity.children = []
137
+ for entity in self.entities.values():
138
+ if entity.parent and entity.parent in self.entities:
139
+ parent = self.entities[entity.parent]
140
+ if entity.id not in parent.children:
141
+ parent.children.append(entity.id)
142
+ for entity in self.entities.values():
143
+ entity.children.sort(key=lambda key: entity_sort_key(self.entities[key]))
144
+
145
+ def roots(self) -> list[ApiEntity]:
146
+ values = [
147
+ entity
148
+ for entity in self.entities.values()
149
+ if not entity.parent or entity.parent not in self.entities
150
+ ]
151
+ return sorted(values, key=entity_sort_key)
152
+
153
+
154
+ def entity_sort_key(entity: ApiEntity) -> tuple[int, str, str]:
155
+ order = {
156
+ "package": 0,
157
+ "module": 1,
158
+ "namespace": 1,
159
+ "class": 2,
160
+ "struct": 2,
161
+ "union": 2,
162
+ "trait": 2,
163
+ "protocol": 2,
164
+ "enum": 3,
165
+ "type_alias": 4,
166
+ "constructor": 5,
167
+ "method": 6,
168
+ "function": 6,
169
+ "property": 7,
170
+ "attribute": 8,
171
+ "variable": 8,
172
+ "constant": 8,
173
+ "macro": 9,
174
+ "concept": 4,
175
+ "deduction_guide": 5,
176
+ }
177
+ return (order.get(entity.kind, 50), entity.name.casefold(), entity.qualified_name)
178
+
179
+
180
+ def stable_entity_id(language: str, kind: str, qualified_name: str, signature_key: str = "") -> str:
181
+ suffix = f"::{signature_key}" if signature_key else ""
182
+ return f"{language}:{kind}:{qualified_name}{suffix}"
183
+
184
+
185
+ def relative_source_path(path: str | Path, project_root: Path) -> str:
186
+ source = Path(path)
187
+ try:
188
+ return source.resolve().relative_to(project_root.resolve()).as_posix()
189
+ except (OSError, ValueError):
190
+ return source.as_posix()
191
+
192
+
193
+ def merge_graphs(
194
+ graphs: Iterable[tuple[str, ApiGraph]], *, project: str, version: str, language: str
195
+ ) -> ApiGraph:
196
+ merged = ApiGraph(project=project, version=version, language=language)
197
+ for variant_name, graph in graphs:
198
+ for key, value in graph.metadata.items():
199
+ merged.metadata.setdefault(key, value)
200
+ merged.variants[variant_name] = graph.variants.get(variant_name, {})
201
+ for source_path, content in graph.sources.items():
202
+ merged.sources.setdefault(source_path, content)
203
+ for entity in graph.entities.values():
204
+ entity_copy = ApiEntity(
205
+ id=entity.id,
206
+ language=entity.language,
207
+ kind=entity.kind,
208
+ name=entity.name,
209
+ qualified_name=entity.qualified_name,
210
+ parent=entity.parent,
211
+ signatures=list(entity.signatures),
212
+ documentation=entity.documentation,
213
+ source=entity.source,
214
+ properties=list(entity.properties),
215
+ variants=list(entity.variants),
216
+ children=list(entity.children),
217
+ bases=list(entity.bases),
218
+ aliases=list(entity.aliases),
219
+ related=list(entity.related),
220
+ template=entity.template,
221
+ declaration=entity.declaration,
222
+ signature_variants=[[variant_name] for _signature in entity.signatures],
223
+ )
224
+ if variant_name not in entity_copy.variants:
225
+ entity_copy.variants.append(variant_name)
226
+ merged.add(entity_copy)
227
+ merged.rebuild_children()
228
+ return merged
@@ -0,0 +1,158 @@
1
+ # SPDX-FileCopyrightText: 2026 cpdocs developers
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Framework-independent helpers used by the ProperDocs cpdocs plugin."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import posixpath
8
+ import re
9
+ import shutil
10
+ from collections.abc import Callable
11
+ from pathlib import Path, PurePosixPath
12
+ from urllib.parse import quote
13
+
14
+ _APIDOCS_REFERENCE = re.compile(
15
+ r"@apidocs(?:\[(?P<version>[^\]]+)\])?::"
16
+ r"(?P<symbol>[A-Za-z_](?:[A-Za-z0-9_:.]*[A-Za-z0-9_])?)"
17
+ )
18
+
19
+
20
+ def repository_provider(repo_url: str, extra: dict[str, object]) -> str:
21
+ explicit = str(extra.get("cpdocs_repo_provider") or "").strip().lower()
22
+ if explicit:
23
+ return explicit
24
+ host = repo_url.lower()
25
+ if "github" in host:
26
+ return "github"
27
+ if "bitbucket" in host:
28
+ return "bitbucket"
29
+ return "gitlab"
30
+
31
+
32
+ def _repository_base(repo_url: str) -> str:
33
+ value = repo_url.rstrip("/")
34
+ return value[:-4] if value.endswith(".git") else value
35
+
36
+
37
+ def source_url(repo_url: str, provider: str, ref: str, source_path: str) -> str:
38
+ base = _repository_base(repo_url)
39
+ ref_part = quote(ref, safe="/")
40
+ path_part = quote(source_path, safe="/")
41
+ if provider == "github":
42
+ return f"{base}/blob/{ref_part}/{path_part}"
43
+ if provider == "bitbucket":
44
+ return f"{base}/src/{ref_part}/{path_part}"
45
+ return f"{base}/-/blob/{ref_part}/{path_part}"
46
+
47
+
48
+ def edit_url(repo_url: str, provider: str, ref: str, source_path: str) -> str:
49
+ base = _repository_base(repo_url)
50
+ ref_part = quote(ref, safe="/")
51
+ path_part = quote(source_path, safe="/")
52
+ if provider == "github":
53
+ return f"{base}/edit/{ref_part}/{path_part}"
54
+ if provider == "bitbucket":
55
+ return f"{base}/src/{ref_part}/{path_part}?mode=edit"
56
+ return f"{base}/-/edit/{ref_part}/{path_part}"
57
+
58
+
59
+ def default_issue_url(repo_url: str, provider: str) -> str:
60
+ base = _repository_base(repo_url)
61
+ return f"{base}/-/issues/new" if provider == "gitlab" else f"{base}/issues/new"
62
+
63
+
64
+ # Fenced code blocks and inline code spans show ``@apidocs`` literally; they are never rewritten.
65
+ _CODE = re.compile(
66
+ r"^(?P<fence>`{3,}|~{3,}).*?^(?P=fence)[ \t]*$|`[^`\n]+`", re.MULTILINE | re.DOTALL
67
+ )
68
+
69
+
70
+ def api_symbol_public_path(symbol: str, version: str, mount: str) -> PurePosixPath:
71
+ return PurePosixPath(mount) / version / "_symbols" / PurePosixPath(*re.split(r"::|\.", symbol))
72
+
73
+
74
+ def resolve_api_references(
75
+ markdown: str,
76
+ *,
77
+ page_dest_uri: str,
78
+ mount: str,
79
+ default_version: str,
80
+ locate: Callable[[str, str], str | None] | None = None,
81
+ missing: list[tuple[str, str]] | None = None,
82
+ ) -> str:
83
+ """Resolve ``@apidocs`` names to links into the versioned API.
84
+
85
+ ``locate(version, symbol)`` returns the entity's page below the version directory, such as
86
+ ``api/demo/struct-settings/``; the link then points straight at it. Without ``locate``, or for
87
+ symbols it does not know, the link points at the stable ``_symbols`` alias, and unknown symbols
88
+ are appended to ``missing`` as ``(version, symbol)``.
89
+ """
90
+
91
+ page_directory = PurePosixPath(page_dest_uri).parent
92
+
93
+ def href_to(target: PurePosixPath, anchor: str = "") -> str:
94
+ relative = posixpath.relpath(target.as_posix(), page_directory.as_posix())
95
+ return relative + "/" + (f"#{anchor}" if anchor else "")
96
+
97
+ def replace(match: re.Match[str]) -> str:
98
+ symbol = match.group("symbol")
99
+ version = match.group("version") or default_version
100
+ found = locate(version, symbol) if locate is not None else None
101
+ if found:
102
+ path, _, anchor = found.partition("#")
103
+ return f"[`{symbol}`]({href_to(PurePosixPath(mount) / version / path, anchor)})"
104
+ if locate is not None and missing is not None:
105
+ missing.append((version, symbol))
106
+ return f"[`{symbol}`]({href_to(api_symbol_public_path(symbol, version, mount))})"
107
+
108
+ parts: list[str] = []
109
+ position = 0
110
+ for code in _CODE.finditer(markdown):
111
+ parts.append(_APIDOCS_REFERENCE.sub(replace, markdown[position : code.start()]))
112
+ parts.append(code.group(0))
113
+ position = code.end()
114
+ parts.append(_APIDOCS_REFERENCE.sub(replace, markdown[position:]))
115
+ return "".join(parts)
116
+
117
+
118
+ _VERSIONS_DIRECTIVE = re.compile(r"^@apidocs-versions[ \t]*$", re.MULTILINE)
119
+
120
+
121
+ def expand_versions_directive(
122
+ markdown: str, *, page_dest_uri: str, mount: str, metadata: dict[str, object]
123
+ ) -> str:
124
+ """Replace a line ``@apidocs-versions`` with a list of every published API version."""
125
+
126
+ if not _VERSIONS_DIRECTIVE.search(markdown):
127
+ return markdown
128
+ page_directory = PurePosixPath(page_dest_uri).parent
129
+ default = metadata.get("default")
130
+ aliases = metadata.get("aliases", {})
131
+ aliases = aliases if isinstance(aliases, dict) else {}
132
+ lines: list[str] = []
133
+ versions = metadata.get("versions", [])
134
+ for entry in versions if isinstance(versions, list) else []:
135
+ if not isinstance(entry, dict):
136
+ continue
137
+ name = str(entry.get("name"))
138
+ target = PurePosixPath(mount) / name
139
+ href = posixpath.relpath(target.as_posix(), page_directory.as_posix()) + "/"
140
+ notes = [str(entry.get("kind", ""))]
141
+ if name == default:
142
+ notes.append("default")
143
+ notes.extend(f"`{alias}`" for alias, value in sorted(aliases.items()) if value == name)
144
+ lines.append(f"- [{name}]({href}) — " + ", ".join(note for note in notes if note))
145
+ listing = "\n".join(lines) if lines else "No API version has been published."
146
+ return _VERSIONS_DIRECTIVE.sub(lambda _match: listing, markdown)
147
+
148
+
149
+ def publish_api(source: Path, site_directory: Path, mount: str) -> Path:
150
+ """Replace the mounted API subtree with cpdocs's complete versioned output."""
151
+
152
+ destination = site_directory / Path(*PurePosixPath(mount).parts)
153
+ if destination.exists():
154
+ shutil.rmtree(destination)
155
+ destination.parent.mkdir(parents=True, exist_ok=True)
156
+ shutil.copytree(source, destination)
157
+ (site_directory / ".nojekyll").touch()
158
+ return destination
@@ -0,0 +1,236 @@
1
+ # SPDX-FileCopyrightText: 2026 cpdocs developers
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """ProperDocs plugin which builds the versioned cpdocs API site and mounts it."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ import re
11
+ import subprocess
12
+ from pathlib import Path, PurePosixPath
13
+
14
+ from properdocs.config import config_options as c
15
+ from properdocs.exceptions import PluginError
16
+ from properdocs.plugins import BasePlugin
17
+
18
+ from cpdocs.build import Layout, build_versions, recorded_default_version
19
+ from cpdocs.config import DEFAULT_CONFIG, CpdocsConfig, load_config
20
+
21
+ from .properdocs_core import (
22
+ default_issue_url,
23
+ edit_url,
24
+ expand_versions_directive,
25
+ publish_api,
26
+ repository_provider,
27
+ resolve_api_references,
28
+ source_url,
29
+ )
30
+
31
+ _LOG = logging.getLogger("properdocs.plugins.cpdocs")
32
+
33
+
34
+ def _symbol_key(symbol: str) -> tuple[str, ...]:
35
+ return tuple(part for part in re.split(r"::|\.", symbol) if part)
36
+
37
+
38
+ class CpdocsPlugin(BasePlugin):
39
+ """Build cpdocs before ProperDocs and mount it into the final static site."""
40
+
41
+ config_scheme = (
42
+ ("config", c.Type(str, default=str(DEFAULT_CONFIG))),
43
+ ("output", c.Type(str, default="build")),
44
+ ("mount", c.Type(str, default="reference/api")),
45
+ )
46
+
47
+ def __init__(self) -> None:
48
+ self._project_root: Path | None = None
49
+ self._cpdocs: CpdocsConfig | None = None
50
+ self._last_fingerprint: tuple[tuple[str, int, int], ...] | None = None
51
+ self._serve_active = False
52
+ self._symbols: dict[str, dict[tuple[str, ...], str]] = {}
53
+
54
+ @property
55
+ def mount(self) -> str:
56
+ value = str(self.config.get("mount", "reference/api")).strip("/")
57
+ if not value:
58
+ raise PluginError("cpdocs: mount must not be the site root")
59
+ return value
60
+
61
+ @property
62
+ def output(self) -> Path:
63
+ assert self._project_root is not None
64
+ path = Path(str(self.config.get("output", "build")))
65
+ return path if path.is_absolute() else self._project_root / path
66
+
67
+ def on_startup(self, *, command: str, dirty: bool = False) -> None:
68
+ del dirty
69
+ self._serve_active = command == "serve"
70
+
71
+ def _config_file(self, config) -> Path:
72
+ value = getattr(config, "config_file_path", None)
73
+ if value:
74
+ return Path(value).resolve()
75
+ return (Path.cwd() / "properdocs.yml").resolve()
76
+
77
+ def on_config(self, config):
78
+ self._project_root = self._config_file(config).parent
79
+ cpdocs_file = Path(str(self.config.get("config", DEFAULT_CONFIG)))
80
+ if not cpdocs_file.is_absolute():
81
+ cpdocs_file = self._project_root / cpdocs_file
82
+ try:
83
+ self._cpdocs = load_config(cpdocs_file)
84
+ except RuntimeError as error:
85
+ raise PluginError(f"cpdocs: {error}") from error
86
+
87
+ extra = dict(config.get("extra", {}) or {})
88
+ repo_url = str(config.get("repo_url") or "").strip()
89
+ if repo_url:
90
+ provider = repository_provider(repo_url, extra)
91
+ extra["cpdocs_repo_provider"] = provider
92
+ extra["cpdocs_source_ref"] = os.environ.get("CPDOCS_SOURCE_REF") or self._source_ref()
93
+ if not extra.get("cpdocs_issue_url"):
94
+ extra["cpdocs_issue_url"] = default_issue_url(repo_url, provider)
95
+ config["extra"] = extra
96
+ return config
97
+
98
+ def _git(self, *arguments: str) -> subprocess.CompletedProcess[str]:
99
+ assert self._project_root is not None
100
+ return subprocess.run(
101
+ ["git", *arguments],
102
+ cwd=self._project_root,
103
+ check=False,
104
+ text=True,
105
+ stdout=subprocess.PIPE,
106
+ stderr=subprocess.DEVNULL,
107
+ )
108
+
109
+ def _source_ref(self) -> str:
110
+ result = self._git("symbolic-ref", "--quiet", "--short", "HEAD")
111
+ if result.returncode == 0 and result.stdout.strip():
112
+ return result.stdout.strip()
113
+ result = self._git("describe", "--tags", "--exact-match")
114
+ return result.stdout.strip() if result.returncode == 0 else "main"
115
+
116
+ def _watched_files(self) -> list[Path]:
117
+ """Files whose change should trigger a rebuild during ``properdocs serve``."""
118
+
119
+ assert self._project_root is not None
120
+ result = self._git("ls-files", "--cached", "--others", "--exclude-standard")
121
+ if result.returncode == 0:
122
+ files = [self._project_root / line for line in result.stdout.splitlines() if line]
123
+ else:
124
+ files = [path for path in self._project_root.rglob("*") if path.is_file()]
125
+ output = self.output.resolve()
126
+ watched = [
127
+ path
128
+ for path in files
129
+ if output not in path.resolve().parents and ".git" not in path.parts
130
+ ]
131
+ refs = self._project_root / ".git" / "refs"
132
+ if refs.is_dir():
133
+ watched.extend(path for path in refs.rglob("*") if path.is_file())
134
+ return watched
135
+
136
+ def _fingerprint(self) -> tuple[tuple[str, int, int], ...]:
137
+ values: list[tuple[str, int, int]] = []
138
+ for path in self._watched_files():
139
+ try:
140
+ stat = path.stat()
141
+ except OSError:
142
+ continue
143
+ values.append((str(path), stat.st_mtime_ns, stat.st_size))
144
+ return tuple(sorted(values))
145
+
146
+ def on_pre_build(self, *, config) -> None:
147
+ del config
148
+ assert self._cpdocs is not None
149
+ fingerprint = self._fingerprint()
150
+ versions = Layout.for_output(self.output).versions
151
+ if self._serve_active and fingerprint == self._last_fingerprint and versions.is_dir():
152
+ return
153
+ try:
154
+ build_versions(
155
+ self._cpdocs,
156
+ output=self.output,
157
+ project_docs_root_depth=len(PurePosixPath(self.mount).parts),
158
+ )
159
+ except RuntimeError as error:
160
+ raise PluginError(f"cpdocs: {error}") from error
161
+ self._last_fingerprint = fingerprint
162
+ self._symbols = {}
163
+
164
+ def _symbol_pages(self, version: str) -> dict[tuple[str, ...], str]:
165
+ if version not in self._symbols:
166
+ path = Layout.for_output(self.output).versions / version / "symbols.json"
167
+ try:
168
+ names = json.loads(path.read_text(encoding="utf-8"))
169
+ except (OSError, json.JSONDecodeError):
170
+ names = {}
171
+ self._symbols[version] = {
172
+ _symbol_key(name): str(target) for name, target in names.items()
173
+ }
174
+ return self._symbols[version]
175
+
176
+ def _locate(self, version: str, symbol: str) -> str | None:
177
+ return self._symbol_pages(version).get(_symbol_key(symbol))
178
+
179
+ def _versions_metadata(self) -> dict[str, object]:
180
+ path = Layout.for_output(self.output).versions / "versions.json"
181
+ try:
182
+ return json.loads(path.read_text(encoding="utf-8"))
183
+ except (OSError, json.JSONDecodeError):
184
+ return {}
185
+
186
+ def on_page_markdown(self, markdown, page, config, files=None):
187
+ del config, files
188
+ assert self._cpdocs is not None
189
+ level = self._cpdocs.check_broken_references
190
+ missing: list[tuple[str, str]] = []
191
+ markdown = expand_versions_directive(
192
+ markdown,
193
+ page_dest_uri=page.file.dest_uri,
194
+ mount=self.mount,
195
+ metadata=self._versions_metadata(),
196
+ )
197
+ result = resolve_api_references(
198
+ markdown,
199
+ page_dest_uri=page.file.dest_uri,
200
+ mount=self.mount,
201
+ default_version=recorded_default_version(self.output),
202
+ locate=self._locate,
203
+ missing=missing,
204
+ )
205
+ if missing and level != "ignore":
206
+ listing = ", ".join(f"{symbol} ({version})" for version, symbol in missing)
207
+ message = f"cpdocs: {page.file.src_uri} references unknown API entities: {listing}"
208
+ if level == "fail":
209
+ raise PluginError(message)
210
+ _LOG.warning(message)
211
+ return result
212
+
213
+ def on_page_context(self, context, page, config, nav=None):
214
+ del nav
215
+ assert self._project_root is not None
216
+ repo_url = str(config.get("repo_url") or "").strip()
217
+ extra = config.get("extra", {}) or {}
218
+ ref = extra.get("cpdocs_source_ref")
219
+ context["cpdocs_source_url"] = None
220
+ context["cpdocs_edit_url"] = None
221
+ if not repo_url or not ref:
222
+ return context
223
+ source = Path(page.file.abs_src_path).resolve()
224
+ try:
225
+ relative_source = source.relative_to(self._project_root).as_posix()
226
+ except ValueError:
227
+ return context
228
+ provider = repository_provider(repo_url, extra)
229
+ context["cpdocs_source_url"] = source_url(repo_url, provider, str(ref), relative_source)
230
+ context["cpdocs_edit_url"] = edit_url(repo_url, provider, str(ref), relative_source)
231
+ return context
232
+
233
+ def on_post_build(self, *, config) -> None:
234
+ publish_api(
235
+ Layout.for_output(self.output).versions, Path(config["site_dir"]).resolve(), self.mount
236
+ )