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.
@@ -0,0 +1,1803 @@
1
+ # SPDX-FileCopyrightText: 2026 cpdocs developers
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Standalone HTML renderer for cpdocs API references.
5
+
6
+ The project documentation is rendered by ProperDocs, but API references have different navigation
7
+ and versioning semantics. This renderer therefore consumes the language-neutral :class:`ApiGraph`
8
+ directly and emits a self-contained code browser without routing the reference through Markdown,
9
+ MkDocs, Sphinx, Doxygen, Breathe, or Exhale.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import functools
16
+ import hashlib
17
+ import html
18
+ import json
19
+ import posixpath
20
+ import re
21
+ import shutil
22
+ from pathlib import Path, PurePosixPath
23
+
24
+ from markdown_it import MarkdownIt
25
+
26
+ from .model import ApiEntity, ApiGraph, ApiSignature, entity_sort_key
27
+ from .render import (
28
+ _CLASS_LIKE_KINDS,
29
+ _documentation_blocks,
30
+ _embedded_member_parent,
31
+ _entity_declaration,
32
+ _highlight_declaration,
33
+ _highlight_source_lines,
34
+ _kind_marker,
35
+ _member_anchor,
36
+ _member_summary_spelling,
37
+ _relative_link,
38
+ _signature_spelling,
39
+ _source_document,
40
+ _source_language,
41
+ _variant_anchor,
42
+ _variant_display_name,
43
+ assign_documents,
44
+ entity_document,
45
+ )
46
+
47
+ _CONTAINER_KINDS = {"namespace", "module", "package"}
48
+ _FUNCTION_KINDS = {"function", "method", "constructor", "deduction_guide"}
49
+ _TYPE_KINDS = {"class", "struct", "union", "enum", "trait", "protocol", "type_alias", "concept"}
50
+ _MEMBER_VALUE_KINDS = {"property", "attribute", "variable", "constant"}
51
+
52
+ _INLINE_DOCUMENTATION = re.compile(
53
+ r"`(?P<code>[^`]+)`"
54
+ r"|\*\*(?P<strong>[^*\n]+?)\*\*"
55
+ r"|\[(?P<label>[^\]]+)\]\((?P<href>[^)]+)\)"
56
+ r"|@projectdocs(?:\{(?P<project_path>[^}]+)\})?"
57
+ )
58
+ _PROJECTDOCS_HREF = re.compile(r'href=(?P<quote>["\'])projectdocs:(?P<path>[^"\']*)(?P=quote)')
59
+
60
+ _MARKDOWN = MarkdownIt("commonmark", {"html": False, "linkify": False, "typographer": False})
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Paths and links
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ def _output_directory(document: Path) -> Path:
69
+ if document.name == "index.md":
70
+ return document.parent
71
+ return document.with_suffix("")
72
+
73
+
74
+ def _output_file(document: Path) -> Path:
75
+ return _output_directory(document) / "index.html"
76
+
77
+
78
+ def _root_relative(document: Path, target: str) -> str:
79
+ directory = _output_directory(document)
80
+ value = posixpath.relpath(target, directory.as_posix() or ".")
81
+ if target.endswith("/") and not value.endswith("/"):
82
+ value += "/"
83
+ return value
84
+
85
+
86
+ def _page_href(source: Path, target: Path, anchor: str = "") -> str:
87
+ value = _relative_link(source, target)
88
+ return value + (f"#{anchor}" if anchor else "")
89
+
90
+
91
+ def _project_docs_target(document: Path, project_docs_url: str, target: str = "") -> str:
92
+ """Resolve one semantic ``projectdocs:`` target for the current API page.
93
+
94
+ ``target`` is always interpreted relative to the root of the non-versioned project site. This
95
+ is deliberately different from a normal relative hyperlink: every historical API version must
96
+ continue to point at the single current project website.
97
+ """
98
+
99
+ base = _project_docs_href(document, project_docs_url)
100
+ target = target.strip()
101
+ if not target:
102
+ return base
103
+ if target.startswith("#"):
104
+ return base + target
105
+
106
+ path, separator, fragment = target.partition("#")
107
+ value = base.rstrip("/") + "/" + path.lstrip("/")
108
+ if path.endswith("/") and not value.endswith("/"):
109
+ value += "/"
110
+ if separator:
111
+ value += "#" + fragment
112
+ return value
113
+
114
+
115
+ def _resolve_projectdocs_links(fragment: str, document: Path, project_docs_url: str) -> str:
116
+ """Resolve semantic project-site links after a page has been placed in the API tree."""
117
+
118
+ def replace(match: re.Match[str]) -> str:
119
+ target = _project_docs_target(
120
+ document, project_docs_url, html.unescape(match.group("path"))
121
+ )
122
+ quote = match.group("quote")
123
+ return f"href={quote}{html.escape(target, quote=True)}{quote}"
124
+
125
+ return _PROJECTDOCS_HREF.sub(replace, fragment)
126
+
127
+
128
+ def _entity_target(entity: ApiEntity, graph: ApiGraph) -> tuple[Path, str]:
129
+ parent = _embedded_member_parent(entity, graph)
130
+ if parent is not None:
131
+ return entity_document(parent), _member_anchor(entity)
132
+ return entity_document(entity), ""
133
+
134
+
135
+ def _entity_href(source: Path, entity: ApiEntity, graph: ApiGraph) -> str:
136
+ target, anchor = _entity_target(entity, graph)
137
+ return _page_href(source, target, anchor)
138
+
139
+
140
+ def _display_source_path(source_path: str, graph: ApiGraph) -> str:
141
+ """Return the source path shown to readers while preserving the real repository path for links."""
142
+ source_root = str(graph.metadata.get("source_root") or "").strip("/")
143
+ logical = PurePosixPath(source_path)
144
+ if not source_root:
145
+ return logical.as_posix()
146
+ root = PurePosixPath(source_root)
147
+ try:
148
+ return logical.relative_to(root).as_posix()
149
+ except ValueError:
150
+ return logical.as_posix()
151
+
152
+
153
+ def _source_href(source_document: Path, entity: ApiEntity, graph: ApiGraph) -> str | None:
154
+ if entity.source is None or entity.source.path not in graph.sources:
155
+ return None
156
+ value = _page_href(source_document, _source_document(entity.source.path))
157
+ if entity.source.line:
158
+ value += f"#L{entity.source.line}"
159
+ return value
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Shared fragments
164
+ # ---------------------------------------------------------------------------
165
+
166
+
167
+ def _kind_label(kind: str) -> str:
168
+ return {
169
+ "namespace": "namespace",
170
+ "module": "module",
171
+ "package": "package",
172
+ "class": "class",
173
+ "struct": "struct",
174
+ "union": "union",
175
+ "enum": "enum",
176
+ "trait": "trait",
177
+ "protocol": "protocol",
178
+ "type_alias": "type alias",
179
+ "concept": "concept",
180
+ "function": "function",
181
+ "method": "method",
182
+ "constructor": "constructor",
183
+ "property": "property",
184
+ "attribute": "attribute",
185
+ "variable": "variable",
186
+ "constant": "constant",
187
+ "macro": "macro",
188
+ "deduction_guide": "deduction guide",
189
+ }.get(kind, kind.replace("_", " "))
190
+
191
+
192
+ def _kind_badge(kind: str) -> str:
193
+ marker = html.escape(_kind_marker(kind))
194
+ return f'<span class="api-kind" title="{html.escape(_kind_label(kind))}">{marker}</span>'
195
+
196
+
197
+ def _legend(graph: ApiGraph) -> str:
198
+ present = {entity.kind for entity in graph.entities.values()}
199
+ order = [
200
+ "namespace",
201
+ "module",
202
+ "package",
203
+ "class",
204
+ "struct",
205
+ "union",
206
+ "enum",
207
+ "trait",
208
+ "protocol",
209
+ "concept",
210
+ "function",
211
+ "macro",
212
+ ]
213
+ entries = []
214
+ for kind in order:
215
+ if kind not in present:
216
+ continue
217
+ entries.append(
218
+ f'<span class="api-legend-entry">{_kind_badge(kind)} {_kind_label(kind)}</span>'
219
+ )
220
+ return (
221
+ '<div class="api-legend">'
222
+ + '<span class="api-legend-separator">·</span>'.join(entries)
223
+ + "</div>"
224
+ )
225
+
226
+
227
+ def _visible_outline_children(entity: ApiEntity, graph: ApiGraph) -> list[ApiEntity]:
228
+ # Public members of records live on the record page and should not duplicate themselves in the
229
+ # global API outline. Namespaces/modules/packages retain their recursive API tree.
230
+ if entity.kind in _CLASS_LIKE_KINDS or entity.kind == "enum":
231
+ return []
232
+ values = [
233
+ graph.entities[child_id]
234
+ for child_id in entity.children
235
+ if child_id in graph.entities and graph.entities[child_id].kind != "macro"
236
+ ]
237
+ return sorted(values, key=entity_sort_key)
238
+
239
+
240
+ def _is_ancestor(candidate: ApiEntity, current: ApiEntity | None, graph: ApiGraph) -> bool:
241
+ entity = current
242
+ while entity is not None:
243
+ if entity.id == candidate.id:
244
+ return True
245
+ if not entity.parent or entity.parent not in graph.entities:
246
+ break
247
+ entity = graph.entities[entity.parent]
248
+ return False
249
+
250
+
251
+ def _outline_entity(
252
+ entity: ApiEntity, graph: ApiGraph, document: Path, current: ApiEntity | None
253
+ ) -> str:
254
+ children = _visible_outline_children(entity, graph)
255
+ expanded = bool(children and _is_ancestor(entity, current, graph))
256
+ active = current is not None and entity.id == current.id
257
+ href = _entity_href(document, entity, graph)
258
+ row = [
259
+ '<div class="api-outline-row' + (" is-current" if active else "") + '">',
260
+ _kind_badge(entity.kind),
261
+ f'<a class="api-outline-link" href="{html.escape(href)}">{html.escape(entity.member_label)}</a>',
262
+ _variant_tag(entity, graph),
263
+ ]
264
+ if children:
265
+ state = "true" if expanded else "false"
266
+ row.append(
267
+ f'<button class="api-outline-toggle" type="button" aria-expanded="{state}" '
268
+ f'aria-label="{"Collapse" if expanded else "Expand"} {html.escape(entity.qualified_name)}"></button>'
269
+ )
270
+ row.append("</div>")
271
+ child_html = ""
272
+ if children:
273
+ hidden = "" if expanded else " hidden"
274
+ child_html = (
275
+ '<ul class="api-outline-children"'
276
+ + hidden
277
+ + ">"
278
+ + "".join(
279
+ "<li>" + _outline_entity(child, graph, document, current) + "</li>"
280
+ for child in children
281
+ )
282
+ + "</ul>"
283
+ )
284
+ return "".join(row) + child_html
285
+
286
+
287
+ def _outline(
288
+ graph: ApiGraph, document: Path, current: ApiEntity | None, *, current_special: str = ""
289
+ ) -> str:
290
+ roots = [entity for entity in graph.roots() if entity.kind != "macro"]
291
+ macros = sorted(
292
+ (entity for entity in graph.entities.values() if entity.kind == "macro"),
293
+ key=entity_sort_key,
294
+ )
295
+ current_label = (
296
+ current.name
297
+ if current is not None
298
+ else {
299
+ "home": "Home",
300
+ "variants": "API Variants",
301
+ "macros": "Macros",
302
+ "source": "Source",
303
+ }.get(current_special, "")
304
+ )
305
+ home_current = current is None and current_special == "home"
306
+ variants_current = current_special == "variants"
307
+ parts = [
308
+ '<nav class="api-outline" aria-label="API outline">',
309
+ "<h2>Outline</h2>",
310
+ # On small screens the outline collapses behind this button, which names the current page.
311
+ '<button class="api-outline-panel-toggle" type="button" aria-expanded="false"'
312
+ ' aria-controls="api-outline-tree"><span>Outline</span>'
313
+ f'<span class="api-outline-current">{html.escape(current_label)}</span></button>',
314
+ '<ul class="api-outline-root" id="api-outline-tree">',
315
+ '<li><div class="api-outline-row' + (" is-current" if home_current else "") + '">',
316
+ '<span class="api-kind api-kind-home">H</span>',
317
+ f'<a class="api-outline-link" href="{html.escape(_page_href(document, Path("index.md")))}">Home</a>',
318
+ "</div></li>",
319
+ ]
320
+ if graph.variants:
321
+ parts.extend(
322
+ [
323
+ '<li><div class="api-outline-row'
324
+ + (" is-current" if variants_current else "")
325
+ + '">',
326
+ '<span class="api-kind api-kind-empty"></span>',
327
+ f'<a class="api-outline-link" href="{html.escape(_page_href(document, Path("api-variants.md")))}">API Variants</a>',
328
+ "</div></li>",
329
+ ]
330
+ )
331
+ for entity in roots:
332
+ parts.append("<li>" + _outline_entity(entity, graph, document, current) + "</li>")
333
+ if macros:
334
+ macro_open = current is not None and current.kind == "macro"
335
+ parts.extend(
336
+ [
337
+ "<li>",
338
+ '<div class="api-outline-row">',
339
+ '<span class="api-kind api-kind-empty"></span>',
340
+ '<a class="api-outline-link" href="'
341
+ + html.escape(_page_href(document, Path("macros.md")))
342
+ + '">Macros</a>',
343
+ f'<button class="api-outline-toggle" type="button" aria-expanded="{"true" if macro_open else "false"}" aria-label="{"Collapse" if macro_open else "Expand"} Macros"></button>',
344
+ "</div>",
345
+ '<ul class="api-outline-children"' + ("" if macro_open else " hidden") + ">",
346
+ ]
347
+ )
348
+ for macro in macros:
349
+ active = current is not None and macro.id == current.id
350
+ parts.append(
351
+ '<li><div class="api-outline-row'
352
+ + (" is-current" if active else "")
353
+ + '">'
354
+ + _kind_badge("macro")
355
+ + f'<a class="api-outline-link" href="{html.escape(_entity_href(document, macro, graph))}">{html.escape(macro.name)}</a>'
356
+ + "</div></li>"
357
+ )
358
+ parts.extend(["</ul>", "</li>"])
359
+ parts.extend(["</ul>", "</nav>"])
360
+ return "".join(parts)
361
+
362
+
363
+ def _toc(items: list[tuple[str, str, int]]) -> str:
364
+ if not items:
365
+ return '<aside class="api-toc"><h2>On this page</h2></aside>'
366
+ parts = ['<aside class="api-toc"><h2>On this page</h2><ul>']
367
+ for label, anchor, level in items:
368
+ parts.append(
369
+ f'<li class="toc-level-{level}"><a href="#{html.escape(anchor)}">{html.escape(label)}</a></li>'
370
+ )
371
+ parts.append("</ul></aside>")
372
+ return "".join(parts)
373
+
374
+
375
+ def _inline_documentation(text: str) -> str:
376
+ """Render the small Markdown-like inline subset accepted in extracted API prose.
377
+
378
+ API comments are intentionally not routed through a full Markdown engine. We only need stable,
379
+ predictable inline constructs here: code spans, Markdown links, and cpdocs's ``@projectdocs``
380
+ shorthand. A Markdown link may use ``projectdocs:`` as its destination and is resolved later,
381
+ once the renderer knows the final depth of the generated API page.
382
+ """
383
+
384
+ result: list[str] = []
385
+ position = 0
386
+ for match in _INLINE_DOCUMENTATION.finditer(text):
387
+ result.append(html.escape(text[position : match.start()]))
388
+ if match.group("code") is not None:
389
+ result.append(f"<code>{html.escape(match.group('code'))}</code>")
390
+ elif match.group("strong") is not None:
391
+ result.append(f"<strong>{html.escape(match.group('strong'))}</strong>")
392
+ elif match.group("label") is not None:
393
+ label = html.escape(match.group("label"))
394
+ href = match.group("href").strip()
395
+ # Reject executable URL schemes in comments. Ordinary relative links, anchors,
396
+ # HTTP(S), mailto and projectdocs semantic links remain available.
397
+ if re.match(r"(?i)^(?:javascript|data|vbscript):", href):
398
+ result.append(label)
399
+ else:
400
+ result.append(f'<a href="{html.escape(href, quote=True)}">{label}</a>')
401
+ else:
402
+ target = (match.group("project_path") or "").strip()
403
+ label = target.rstrip("/") or "project documentation"
404
+ result.append(
405
+ f'<a href="projectdocs:{html.escape(target, quote=True)}">{html.escape(label)}</a>'
406
+ )
407
+ position = match.end()
408
+ result.append(html.escape(text[position:]))
409
+ return "".join(result)
410
+
411
+
412
+ _DOCUMENTATION_FIELD = re.compile(
413
+ r"@(?P<command>tparam|param|returns?|throws?)\b\s*(?P<rest>.*)", re.DOTALL
414
+ )
415
+ _FIELD_TITLES = {
416
+ "tparam": "Template parameters",
417
+ "param": "Parameters",
418
+ "return": "Returns",
419
+ "throws": "Throws",
420
+ }
421
+
422
+
423
+ def _documentation_description(text: str) -> str:
424
+ """Render documentation prose, followed by its parameter, return and exception fields.
425
+
426
+ Paragraphs of the form ``@tparam NAME text``, ``@param NAME text``, ``@return text`` and
427
+ ``@throws text`` are collected, wherever they appear, into titled lists after the prose.
428
+ """
429
+
430
+ paragraphs: list[str] = []
431
+ fields: dict[str, list[tuple[str, str]]] = {}
432
+ for block in re.split(r"\n\s*\n", text.strip()):
433
+ paragraph = block.strip()
434
+ if not paragraph or paragraph.startswith("CPDOCS-API-RELATES-TO:"):
435
+ continue
436
+ # Doc-comment line wrapping is not semantically significant. Keep explicit Markdown-like
437
+ # inline syntax intact while presenting the prose as a normal paragraph.
438
+ normalized = " ".join(line.strip() for line in paragraph.splitlines())
439
+ field = _DOCUMENTATION_FIELD.fullmatch(normalized)
440
+ if field is None:
441
+ paragraphs.append(f"<p>{_inline_documentation(normalized)}</p>")
442
+ continue
443
+ command = {"returns": "return", "throw": "throws"}.get(
444
+ field.group("command"), field.group("command")
445
+ )
446
+ rest = field.group("rest").strip()
447
+ if command in {"tparam", "param"}:
448
+ name, _, description = rest.partition(" ")
449
+ fields.setdefault(command, []).append((name, description.strip()))
450
+ else:
451
+ fields.setdefault(command, []).append(("", rest))
452
+ for command in ("tparam", "param", "return", "throws"):
453
+ entries = fields.get(command)
454
+ if not entries:
455
+ continue
456
+ paragraphs.append(
457
+ f'<div class="api-doc-field"><p class="api-doc-field-title">{_FIELD_TITLES[command]}</p>'
458
+ )
459
+ if command in {"tparam", "param"}:
460
+ paragraphs.append(
461
+ "<dl>"
462
+ + "".join(
463
+ f"<dt><code>{html.escape(name)}</code></dt><dd>{_inline_documentation(description)}</dd>"
464
+ for name, description in entries
465
+ )
466
+ + "</dl>"
467
+ )
468
+ else:
469
+ paragraphs.extend(f"<p>{_inline_documentation(value)}</p>" for _name, value in entries)
470
+ paragraphs.append("</div>")
471
+ return "".join(paragraphs)
472
+
473
+
474
+ def _documentation(entity: ApiEntity, graph: ApiGraph, document: Path) -> str:
475
+ _, related = _documentation_blocks(entity, graph)
476
+ description = _documentation_description(entity.documentation)
477
+ if not description and not related:
478
+ description = '<p class="api-undocumented">No API documentation has been provided.</p>'
479
+ if not related:
480
+ return description
481
+ links: list[str] = []
482
+ for text, target in related:
483
+ if target is None:
484
+ links.append(f"<code>{html.escape(text)}</code>")
485
+ else:
486
+ links.append(
487
+ f'<a href="{html.escape(_entity_href(document, target, graph))}"><code>{html.escape(target.name)}</code></a>'
488
+ )
489
+ return (
490
+ description
491
+ + '<p class="api-related"><strong>Related:</strong> '
492
+ + " · ".join(links)
493
+ + "</p>"
494
+ )
495
+
496
+
497
+ def _availability(entity: ApiEntity, document: Path, variants: list[str] | None = None) -> str:
498
+ variants = variants if variants else entity.variants
499
+ if not variants:
500
+ return ""
501
+ links = []
502
+ for variant in variants:
503
+ href = _page_href(document, Path("api-variants.md"), _variant_anchor(variant))
504
+ links.append(
505
+ f'<a href="{html.escape(href)}">{html.escape(_variant_display_name(variant))}</a>'
506
+ )
507
+ links.append(
508
+ f'<a href="{html.escape(_page_href(document, Path("api-variants.md")))}">About API variants</a>'
509
+ )
510
+ return (
511
+ '<div class="api-availability"><strong>API variants</strong><span>'
512
+ + " · ".join(links)
513
+ + "</span></div>"
514
+ )
515
+
516
+
517
+ def _defined_in(entity: ApiEntity, graph: ApiGraph, document: Path) -> str:
518
+ if entity.kind in _CONTAINER_KINDS or entity.source is None:
519
+ return ""
520
+ href = _source_href(document, entity, graph)
521
+ label = Path(entity.source.path).name
522
+ if href:
523
+ return f'<p class="api-defined">Defined in <a href="{html.escape(href)}">{html.escape(label)}</a></p>'
524
+ return f'<p class="api-defined">Defined in <code>{html.escape(label)}</code></p>'
525
+
526
+
527
+ def _definition_row(entity: ApiEntity, graph: ApiGraph, document: Path) -> str:
528
+ if entity.source is None:
529
+ return ""
530
+ href = _source_href(document, entity, graph)
531
+ location = _display_source_path(entity.source.path, graph) + (
532
+ f":{entity.source.line}" if entity.source.line else ""
533
+ )
534
+ value = (
535
+ f'<a href="{html.escape(href)}"><code>{html.escape(location)}</code></a>'
536
+ if href
537
+ else f"<code>{html.escape(location)}</code>"
538
+ )
539
+ return '<div class="api-meta-row"><strong>Definition</strong><span>' + value + "</span></div>"
540
+
541
+
542
+ def _signature_card(
543
+ entity: ApiEntity,
544
+ signature: ApiSignature,
545
+ graph: ApiGraph,
546
+ document: Path,
547
+ *,
548
+ anchor: str,
549
+ documentation: bool = True,
550
+ ) -> str:
551
+ if signature.documentation or signature.source is not None:
552
+ entity = dataclasses.replace(
553
+ entity,
554
+ documentation=signature.documentation or entity.documentation,
555
+ source=signature.source or entity.source,
556
+ )
557
+ spelling = _signature_spelling(entity, signature)
558
+ declaration = _highlight_declaration(
559
+ spelling, entity.language, entity=entity, graph=graph, document_path=document
560
+ )
561
+ properties = list(dict.fromkeys([*entity.properties, *signature.qualifiers]))
562
+ rows = []
563
+ if properties:
564
+ rows.append(
565
+ '<div class="api-meta-row"><strong>Properties</strong><span class="api-badges">'
566
+ + "".join(f"<span>{html.escape(value)}</span>" for value in properties)
567
+ + "</span></div>"
568
+ )
569
+ if entity.source is not None:
570
+ rows.append(_definition_row(entity, graph, document))
571
+ body = _documentation(entity, graph, document) if documentation else ""
572
+ return (
573
+ f'<article class="api-signature-card" id="{html.escape(anchor)}">'
574
+ f'<pre class="api-signature"><code class="language-{html.escape(entity.language)}">{declaration}</code></pre>'
575
+ + "".join(rows)
576
+ + (f'<div class="api-description">{body}</div>' if body else "")
577
+ + _availability(entity, document, _signature_variants(entity, signature))
578
+ + "</article>"
579
+ )
580
+
581
+
582
+ def _signature_variants(entity: ApiEntity, signature: ApiSignature) -> list[str]:
583
+ if signature not in entity.signatures:
584
+ return []
585
+ index = entity.signatures.index(signature)
586
+ if index < len(entity.signature_variants):
587
+ return entity.signature_variants[index]
588
+ return []
589
+
590
+
591
+ def _variant_tag(entity: ApiEntity, graph: ApiGraph) -> str:
592
+ """Compact marker for entities that exist only in some variants."""
593
+
594
+ if not entity.variants or len(graph.variants) < 2:
595
+ return ""
596
+ if set(graph.variants) <= set(entity.variants):
597
+ return ""
598
+ names = " · ".join(_variant_display_name(value) for value in entity.variants)
599
+ return f'<span class="api-variant-tag" title="Only in: {html.escape(names)}">{html.escape(names)}</span>'
600
+
601
+
602
+ def _compact_signature_label(entity: ApiEntity, signature: ApiSignature) -> str:
603
+ if entity.kind == "macro":
604
+ return entity.name
605
+ values = [parameter.type or parameter.name for parameter in signature.parameters]
606
+ return f"{entity.name}({', '.join(values)})"
607
+
608
+
609
+ def _member_anchor_for_signature(entity: ApiEntity, index: int) -> str:
610
+ base = _member_anchor(entity)
611
+ return base if index == 0 else f"{base}-{index + 1}"
612
+
613
+
614
+ # ---------------------------------------------------------------------------
615
+ # Page bodies
616
+ # ---------------------------------------------------------------------------
617
+
618
+
619
+ def _namespace_page(
620
+ entity: ApiEntity, graph: ApiGraph, document: Path
621
+ ) -> tuple[str, list[tuple[str, str, int]]]:
622
+ title = f"{_kind_label(entity.kind).title()} {entity.qualified_name}"
623
+ body = [f"<h1>{html.escape(title)}</h1>"]
624
+ if entity.documentation.strip():
625
+ body.append(
626
+ '<div class="api-namespace-doc">' + _documentation(entity, graph, document) + "</div>"
627
+ )
628
+ else:
629
+ body.append(
630
+ '<div class="api-namespace-doc api-namespace-doc-generated">'
631
+ f"<p>The <code>{html.escape(entity.qualified_name)}</code> namespace groups the public API "
632
+ "declared in this scope. The hierarchy below expands nested namespaces through their "
633
+ "public types and function families; members of classes and structs remain documented "
634
+ "on their corresponding type pages.</p>"
635
+ "</div>"
636
+ )
637
+ body.extend(
638
+ [
639
+ '<section id="members" class="api-section">',
640
+ "<h2>Members</h2>",
641
+ _legend(graph),
642
+ '<ul class="api-namespace-tree">',
643
+ ]
644
+ )
645
+
646
+ def render_namespace_children(container: ApiEntity) -> str:
647
+ children = [
648
+ graph.entities[value]
649
+ for value in container.children
650
+ if value in graph.entities and graph.entities[value].kind != "macro"
651
+ ]
652
+ children.sort(key=entity_sort_key)
653
+ parts: list[str] = []
654
+ for child in children:
655
+ parts.append('<li><div class="api-namespace-tree-row">')
656
+ parts.append(_kind_badge(child.kind))
657
+ parts.append(
658
+ f'<a href="{html.escape(_entity_href(document, child, graph))}">{html.escape(child.member_label)}</a>'
659
+ )
660
+ parts.append("</div>")
661
+ if child.kind in _CONTAINER_KINDS:
662
+ nested = render_namespace_children(child)
663
+ if nested:
664
+ parts.append("<ul>" + nested + "</ul>")
665
+ parts.append("</li>")
666
+ return "".join(parts)
667
+
668
+ body.append(render_namespace_children(entity))
669
+ body.extend(["</ul>", _availability(entity, document), "</section>"])
670
+ return "".join(body), [("Members", "members", 1)]
671
+
672
+
673
+ def _record_page(
674
+ entity: ApiEntity, graph: ApiGraph, document: Path
675
+ ) -> tuple[str, list[tuple[str, str, int]]]:
676
+ children = [graph.entities[value] for value in entity.children if value in graph.entities]
677
+ children.sort(key=entity_sort_key)
678
+ body = [f"<h1>{html.escape(entity.name)}</h1>", _defined_in(entity, graph, document)]
679
+ if entity.language in {"c", "cpp"} and entity.kind in {"class", "struct", "union", "enum"}:
680
+ scoped = " class" if entity.kind == "enum" and "scoped" in entity.properties else ""
681
+ bases = f" : {', '.join(entity.bases)}" if entity.bases else ""
682
+ declaration_text = f"{entity.kind}{scoped} {entity.name}{bases}"
683
+ if entity.template:
684
+ declaration_text = f"{entity.template}\n{declaration_text}"
685
+ elif entity.language == "python" and entity.kind in {"class", "protocol"}:
686
+ bases = f"({', '.join(entity.bases)})" if entity.bases else ""
687
+ declaration_text = f"class {entity.name}{bases}"
688
+ elif entity.language == "rust" and entity.kind in {"struct", "enum", "trait"}:
689
+ prefix = "pub " if "pub" in entity.properties else ""
690
+ declaration_text = f"{prefix}{entity.kind} {entity.name}"
691
+ else:
692
+ declaration_text = _entity_declaration(entity)
693
+ declaration = _highlight_declaration(
694
+ declaration_text, entity.language, entity=entity, graph=graph, document_path=document
695
+ )
696
+ body.append(
697
+ '<article class="api-entity-card">'
698
+ f'<pre class="api-signature"><code class="language-{html.escape(entity.language)}">{declaration}</code></pre>'
699
+ + _definition_row(entity, graph, document)
700
+ + '<div class="api-description">'
701
+ + _documentation(entity, graph, document)
702
+ + "</div>"
703
+ + _availability(entity, document)
704
+ + "</article>"
705
+ )
706
+
707
+ toc: list[tuple[str, str, int]] = []
708
+ specializations = _specializations(entity, graph)
709
+ if specializations:
710
+ body.append(
711
+ '<section id="specializations" class="api-section"><h2>Specializations</h2>'
712
+ '<div class="api-record-members">'
713
+ )
714
+ toc.append(("Specializations", "specializations", 1))
715
+ for other in specializations:
716
+ # Only the specialization's name is a link, as in every other member row; the
717
+ # highlighted prefix must not contain links of its own, since links cannot nest.
718
+ prefix = _highlight_declaration(
719
+ f"{other.template} {other.kind}".strip(),
720
+ other.language,
721
+ entity=other,
722
+ graph=graph,
723
+ document_path=document,
724
+ )
725
+ href = html.escape(_entity_href(document, other, graph))
726
+ body.append(
727
+ '<div class="api-member-row">'
728
+ + _kind_badge(other.kind)
729
+ + f'<code>{prefix} <a href="{href}">{html.escape(other.name)}</a></code>'
730
+ + (
731
+ '<div class="api-member-doc">'
732
+ + _documentation(other, graph, document)
733
+ + "</div>"
734
+ if other.documentation.strip()
735
+ else ""
736
+ )
737
+ + "</div>"
738
+ )
739
+ body.append("</div></section>")
740
+
741
+ grouped = {
742
+ "Public Types": [
743
+ child for child in children if child.kind in _TYPE_KINDS or child.kind in {"type_alias"}
744
+ ],
745
+ "Public Functions": [
746
+ child
747
+ for child in children
748
+ if child.kind in _FUNCTION_KINDS and child.kind != "deduction_guide"
749
+ ],
750
+ "Deduction Guides": [child for child in children if child.kind == "deduction_guide"],
751
+ "Public Members": [child for child in children if child.kind in _MEMBER_VALUE_KINDS],
752
+ }
753
+ for heading, values in grouped.items():
754
+ if not values:
755
+ continue
756
+ section_anchor = re.sub(r"[^a-z0-9]+", "-", heading.lower()).strip("-")
757
+ body.append(
758
+ f'<section id="{section_anchor}" class="api-section"><h2>{html.escape(heading)}</h2>'
759
+ )
760
+ toc.append((heading, section_anchor, 1))
761
+ body.append('<div class="api-record-members">')
762
+ for child in values:
763
+ if child.kind in _FUNCTION_KINDS and child.signatures:
764
+ for index, signature in enumerate(child.signatures):
765
+ anchor = _member_anchor_for_signature(child, index)
766
+ label = _compact_signature_label(child, signature)
767
+ toc.append((label, anchor, 2))
768
+ body.append(_signature_card(child, signature, graph, document, anchor=anchor))
769
+ elif child.kind in _MEMBER_VALUE_KINDS:
770
+ anchor = _member_anchor(child)
771
+ toc.append((child.name, anchor, 2))
772
+ summary = _member_summary_spelling(child)
773
+ highlighted = _highlight_declaration(
774
+ summary, child.language, entity=child, graph=graph, document_path=document
775
+ )
776
+ body.append(
777
+ f'<div class="api-member-row" id="{html.escape(anchor)}">'
778
+ + _kind_badge(child.kind)
779
+ + f"<code>{highlighted}</code>"
780
+ + (
781
+ '<div class="api-member-doc">'
782
+ + _documentation(child, graph, document)
783
+ + "</div>"
784
+ if child.documentation.strip()
785
+ else ""
786
+ )
787
+ + "</div>"
788
+ )
789
+ else:
790
+ anchor = _member_anchor(child)
791
+ toc.append((child.name, anchor, 2))
792
+ body.append(
793
+ f'<div class="api-member-row" id="{html.escape(anchor)}">'
794
+ + _kind_badge(child.kind)
795
+ + f'<a href="{html.escape(_entity_href(document, child, graph))}">{html.escape(child.name)}</a></div>'
796
+ )
797
+ body.extend(["</div>", "</section>"])
798
+ return "".join(body), toc
799
+
800
+
801
+ def _specializations(entity: ApiEntity, graph: ApiGraph) -> list[ApiEntity]:
802
+ """Written specializations of a primary class template, which are its siblings."""
803
+
804
+ if "<" in entity.name or not entity.template:
805
+ return []
806
+ values = [
807
+ other
808
+ for other in graph.entities.values()
809
+ if other.parent == entity.parent
810
+ and other.language == entity.language
811
+ and other.name.startswith(entity.name + "<")
812
+ ]
813
+ return sorted(values, key=lambda other: other.name)
814
+
815
+
816
+ def _function_page(
817
+ entity: ApiEntity, graph: ApiGraph, document: Path
818
+ ) -> tuple[str, list[tuple[str, str, int]]]:
819
+ body = [f"<h1>{html.escape(entity.name)}</h1>", _defined_in(entity, graph, document)]
820
+ toc: list[tuple[str, str, int]] = []
821
+ signatures = entity.signatures or [ApiSignature()]
822
+ for index, signature in enumerate(signatures):
823
+ anchor = f"overload-{index + 1}"
824
+ label = _compact_signature_label(entity, signature)
825
+ toc.append((label, anchor, 1))
826
+ body.append(_signature_card(entity, signature, graph, document, anchor=anchor))
827
+ return "".join(body), toc
828
+
829
+
830
+ def _enum_page(
831
+ entity: ApiEntity, graph: ApiGraph, document: Path
832
+ ) -> tuple[str, list[tuple[str, str, int]]]:
833
+ return _record_page(entity, graph, document)
834
+
835
+
836
+ def _simple_entity_page(
837
+ entity: ApiEntity, graph: ApiGraph, document: Path
838
+ ) -> tuple[str, list[tuple[str, str, int]]]:
839
+ body = [f"<h1>{html.escape(entity.name)}</h1>", _defined_in(entity, graph, document)]
840
+ if entity.signatures:
841
+ for index, signature in enumerate(entity.signatures):
842
+ body.append(
843
+ _signature_card(entity, signature, graph, document, anchor=f"signature-{index + 1}")
844
+ )
845
+ else:
846
+ declaration = _highlight_declaration(
847
+ _entity_declaration(entity),
848
+ entity.language,
849
+ entity=entity,
850
+ graph=graph,
851
+ document_path=document,
852
+ )
853
+ body.append(
854
+ '<article class="api-entity-card">'
855
+ f'<pre class="api-signature"><code class="language-{html.escape(entity.language)}">{declaration}</code></pre>'
856
+ + _definition_row(entity, graph, document)
857
+ + '<div class="api-description">'
858
+ + _documentation(entity, graph, document)
859
+ + "</div>"
860
+ + _availability(entity, document)
861
+ + "</article>"
862
+ )
863
+ return "".join(body), []
864
+
865
+
866
+ def _entity_page(
867
+ entity: ApiEntity, graph: ApiGraph, document: Path
868
+ ) -> tuple[str, list[tuple[str, str, int]]]:
869
+ if entity.kind in _CONTAINER_KINDS:
870
+ return _namespace_page(entity, graph, document)
871
+ if entity.kind in _CLASS_LIKE_KINDS or entity.kind == "enum":
872
+ return _record_page(entity, graph, document)
873
+ if entity.kind in _FUNCTION_KINDS:
874
+ return _function_page(entity, graph, document)
875
+ return _simple_entity_page(entity, graph, document)
876
+
877
+
878
+ def _hierarchy_entity(entity: ApiEntity, graph: ApiGraph, document: Path) -> str:
879
+ children = _visible_outline_children(entity, graph)
880
+ parts = [
881
+ "<li>",
882
+ _kind_badge(entity.kind),
883
+ f'<a href="{html.escape(_entity_href(document, entity, graph))}">{html.escape(entity.member_label)}</a>',
884
+ _variant_tag(entity, graph),
885
+ ]
886
+ if children:
887
+ parts.append("<ul>")
888
+ parts.extend(_hierarchy_entity(child, graph, document) for child in children)
889
+ parts.append("</ul>")
890
+ parts.append("</li>")
891
+ return "".join(parts)
892
+
893
+
894
+ def _file_tree(graph: ApiGraph) -> dict[str, object]:
895
+ root: dict[str, object] = {}
896
+ for source in sorted(graph.sources):
897
+ node = root
898
+ display_source = _display_source_path(source, graph)
899
+ parts = PurePosixPath(display_source).parts
900
+ for part in parts[:-1]:
901
+ node = node.setdefault(part, {}) # type: ignore[assignment]
902
+ node.setdefault("__files__", []).append((parts[-1], source)) # type: ignore[union-attr]
903
+ return root
904
+
905
+
906
+ def _file_tree_html(node: dict[str, object], document: Path) -> str:
907
+ parts = ['<ul class="api-file-tree">']
908
+ files = node.get("__files__", [])
909
+ if isinstance(files, list):
910
+ for filename, source_path in sorted(files, key=lambda item: str(item[0]).casefold()):
911
+ parts.append(
912
+ '<li><span class="api-kind file-kind" title="file">F</span>'
913
+ f'<a href="{html.escape(_page_href(document, _source_document(str(source_path))))}">{html.escape(str(filename))}</a></li>'
914
+ )
915
+ for name, child in sorted(node.items()):
916
+ if name == "__files__" or not isinstance(child, dict):
917
+ continue
918
+ parts.append(
919
+ f'<li><span class="api-kind file-kind" title="directory">D</span>'
920
+ f"<span>{html.escape(name)}</span>"
921
+ )
922
+ parts.append(_file_tree_html(child, document))
923
+ parts.append("</li>")
924
+ parts.append("</ul>")
925
+ return "".join(parts)
926
+
927
+
928
+ _HTML_COMMENT = re.compile(r"<!--.*?-->[ \t]*\n?", re.DOTALL)
929
+
930
+
931
+ def _render_markdown(text: str, graph: ApiGraph) -> str:
932
+ """Render human-authored Markdown used by the standalone API site.
933
+
934
+ Raw HTML is not rendered, so HTML comments such as the SPDX header of the introduction file
935
+ are removed instead of being shown as text.
936
+ """
937
+
938
+ substituted = (
939
+ _HTML_COMMENT.sub("", text)
940
+ .replace("{{ project }}", graph.project)
941
+ .replace("{{ version }}", graph.version)
942
+ .replace("{{ language }}", graph.language)
943
+ )
944
+ return _MARKDOWN.render(substituted)
945
+
946
+
947
+ def _home_page(
948
+ graph: ApiGraph,
949
+ document: Path,
950
+ project_docs_url: str,
951
+ *,
952
+ introduction: str | None = None,
953
+ ) -> tuple[str, list[tuple[str, str, int]]]:
954
+ del project_docs_url
955
+ roots = [entity for entity in graph.roots() if entity.kind != "macro"]
956
+ macros = sorted(
957
+ (entity for entity in graph.entities.values() if entity.kind == "macro"),
958
+ key=entity_sort_key,
959
+ )
960
+ if introduction is None:
961
+ introduction = (
962
+ "# API Documentation Home\n\n"
963
+ "This site is the generated API reference for this version of `{{ project }}`. "
964
+ "For tutorials, how-to guides, explanations, and general project information, see the "
965
+ "[main project documentation](projectdocs:).\n\n"
966
+ "cpdocs extracts semantic declarations into a language-neutral API graph and renders this "
967
+ "versioned reference directly as a code-oriented site.\n\n"
968
+ "The [API variants and features](api-variants/) page explains how configured features select "
969
+ "entity variants, which inputs participate, and which parser predefinitions are used.\n"
970
+ )
971
+ body = [
972
+ _render_markdown(introduction, graph),
973
+ '<section id="api-hierarchy" class="api-section"><h2>API hierarchy</h2>',
974
+ _legend(graph),
975
+ '<ul class="api-hierarchy">',
976
+ ]
977
+ body.extend(_hierarchy_entity(entity, graph, document) for entity in roots)
978
+ if macros:
979
+ body.append("<li><strong>Macros</strong><ul>")
980
+ for macro in macros:
981
+ body.append(
982
+ "<li>"
983
+ + _kind_badge("macro")
984
+ + f'<a href="{html.escape(_entity_href(document, macro, graph))}">{html.escape(macro.name)}</a></li>'
985
+ )
986
+ body.append("</ul></li>")
987
+ body.extend(
988
+ [
989
+ "</ul></section>",
990
+ '<section id="file-hierarchy" class="api-section"><h2>File Hierarchy</h2>',
991
+ _file_tree_html(_file_tree(graph), document),
992
+ "</section>",
993
+ ]
994
+ )
995
+ return "".join(body), [
996
+ ("API hierarchy", "api-hierarchy", 1),
997
+ ("File Hierarchy", "file-hierarchy", 1),
998
+ ]
999
+
1000
+
1001
+ def _variant_page(graph: ApiGraph, document: Path) -> tuple[str, list[tuple[str, str, int]]]:
1002
+ del document
1003
+ axes = graph.metadata.get("features", {})
1004
+ if not isinstance(axes, dict):
1005
+ axes = {}
1006
+ units = graph.metadata.get("units", [])
1007
+ if not isinstance(units, list):
1008
+ units = []
1009
+
1010
+ def chips(values: object) -> str:
1011
+ if not isinstance(values, list) or not values:
1012
+ return '<span class="api-none">none</span>'
1013
+ return " ".join(
1014
+ f'<code class="api-chip">{html.escape(str(value))}</code>' for value in values
1015
+ )
1016
+
1017
+ def selected(name: str, axis: str) -> list[str]:
1018
+ variant = graph.variants.get(name, {})
1019
+ features = variant.get("features", {}) if isinstance(variant, dict) else {}
1020
+ values = features.get(axis, []) if isinstance(features, dict) else []
1021
+ return [str(value) for value in values] if isinstance(values, list) else []
1022
+
1023
+ primary = next(
1024
+ (
1025
+ name
1026
+ for name, value in graph.variants.items()
1027
+ if isinstance(value, dict) and value.get("primary")
1028
+ ),
1029
+ None,
1030
+ )
1031
+ unit_text = " · ".join(
1032
+ f"<code>{html.escape(str(unit.get('id')))}</code> ({html.escape(str(unit.get('language')))})"
1033
+ for unit in units
1034
+ if isinstance(unit, dict)
1035
+ )
1036
+ body = [
1037
+ "<h1>API Variants</h1>",
1038
+ "<p>The project's build system declares feature axes and the variants worth documenting. "
1039
+ "cpdocs extracts the API once per variant and merges the results, so every entity records "
1040
+ "the variants in which it exists.</p>",
1041
+ '<section id="overview" class="api-section"><h2>Overview</h2>',
1042
+ "<table><tbody>",
1043
+ f"<tr><th>Project</th><td><code>{html.escape(graph.project)}</code></td></tr>",
1044
+ "<tr><th>Variants</th><td>"
1045
+ + " · ".join(
1046
+ html.escape(_variant_display_name(name)) + (" (primary)" if name == primary else "")
1047
+ for name in graph.variants
1048
+ )
1049
+ + "</td></tr>",
1050
+ ]
1051
+ if unit_text:
1052
+ body.append(f"<tr><th>Units</th><td>{unit_text}</td></tr>")
1053
+ generator = graph.metadata.get("generator")
1054
+ if generator:
1055
+ body.append(f"<tr><th>Described by</th><td>{html.escape(str(generator))}</td></tr>")
1056
+ body.append("</tbody></table></section>")
1057
+ toc: list[tuple[str, str, int]] = [("Overview", "overview", 1)]
1058
+
1059
+ if axes:
1060
+ names = list(graph.variants)
1061
+ # With one axis its name heads the value column and each row is just the value. With
1062
+ # several axes, each axis gets a group row and its values are listed below it.
1063
+ single = len(axes) == 1
1064
+ axis_name = str(next(iter(axes)))
1065
+ intro = (
1066
+ f"Which values of <code>{html.escape(axis_name)}</code> each variant selects."
1067
+ if single
1068
+ else "Which feature values each variant selects."
1069
+ )
1070
+ first_header = f"<code>{html.escape(axis_name)}</code>" if single else "Feature"
1071
+ body.extend(
1072
+ [
1073
+ '<section id="features" class="api-section"><h2>Features</h2>',
1074
+ f'<p class="api-section-intro">{intro}</p>',
1075
+ f'<div class="table-scroll"><table><thead><tr><th scope="col">{first_header}</th>'
1076
+ + "".join(
1077
+ f'<th scope="col">{html.escape(_variant_display_name(name))}</th>'
1078
+ for name in names
1079
+ )
1080
+ + "</tr></thead><tbody>",
1081
+ ]
1082
+ )
1083
+ for axis, values in axes.items():
1084
+ if not single:
1085
+ body.append(
1086
+ f'<tr class="api-feature-axis"><th scope="rowgroup" colspan="{len(names) + 1}">'
1087
+ f"<code>{html.escape(str(axis))}</code></th></tr>"
1088
+ )
1089
+ for value in values if isinstance(values, list) else []:
1090
+ row = [f'<th scope="row"><code>{html.escape(str(value))}</code></th>']
1091
+ for name in names:
1092
+ row.append(
1093
+ "<td>yes</td>" if str(value) in selected(name, str(axis)) else "<td>—</td>"
1094
+ )
1095
+ body.append("<tr>" + "".join(row) + "</tr>")
1096
+ body.append("</tbody></table></div></section>")
1097
+ toc.append(("Features", "features", 1))
1098
+
1099
+ body.extend(
1100
+ [
1101
+ '<section id="api-variants" class="api-section"><h2>Variants</h2>',
1102
+ '<div class="api-variant-grid">',
1103
+ ]
1104
+ )
1105
+ toc.append(("Variants", "api-variants", 1))
1106
+ for name in graph.variants:
1107
+ anchor = _variant_anchor(name)
1108
+ display = _variant_display_name(name)
1109
+ label = "Primary variant" if name == primary else "Variant"
1110
+ body.extend(
1111
+ [
1112
+ f'<article id="{html.escape(anchor)}" class="api-variant-card">',
1113
+ f'<header><h3>{html.escape(display)}</h3><span class="api-variant-card-label">{label}</span></header>',
1114
+ "<dl>",
1115
+ ]
1116
+ )
1117
+ for axis in axes:
1118
+ body.append(
1119
+ f"<div><dt>{html.escape(str(axis))}</dt><dd>"
1120
+ + chips(selected(name, str(axis)))
1121
+ + "</dd></div>"
1122
+ )
1123
+ if not axes:
1124
+ body.append(
1125
+ '<div><dt>Features</dt><dd><span class="api-none">none declared</span></dd></div>'
1126
+ )
1127
+ body.append("</dl></article>")
1128
+ toc.append((display, anchor, 2))
1129
+ body.append("</div></section>")
1130
+ return "".join(body), toc
1131
+
1132
+
1133
+ def _macros_page(graph: ApiGraph, document: Path) -> tuple[str, list[tuple[str, str, int]]]:
1134
+ macros = sorted(
1135
+ (entity for entity in graph.entities.values() if entity.kind == "macro"),
1136
+ key=entity_sort_key,
1137
+ )
1138
+ body = ['<h1>Macros</h1><ul class="api-member-list">']
1139
+ for macro in macros:
1140
+ body.append(
1141
+ "<li>"
1142
+ + _kind_badge("macro")
1143
+ + f'<a href="{html.escape(_entity_href(document, macro, graph))}">{html.escape(macro.name)}</a></li>'
1144
+ )
1145
+ body.append("</ul>")
1146
+ return "".join(body), []
1147
+
1148
+
1149
+ def _source_page(
1150
+ source_path: str, content: str, document: Path, graph: ApiGraph
1151
+ ) -> tuple[str, list[tuple[str, str, int]]]:
1152
+ language = _source_language(source_path)
1153
+ if language == "cpp":
1154
+ # Some generated headers align macro-continuation backslashes with large runs of spaces.
1155
+ # That is useful in raw source but produces visually detached backslashes in the browser.
1156
+ # Preserve the source semantics while normalizing only the rendered representation.
1157
+ content = "\n".join(
1158
+ re.sub(r"[ \t]+\\[ \t]*$", r" \\", line.rstrip()) for line in content.splitlines()
1159
+ ) + ("\n" if content.endswith("\n") else "")
1160
+ highlighted = _highlight_source_lines(content, language)
1161
+ display_path = _display_source_path(source_path, graph)
1162
+ generated = graph.metadata.get("generated_sources", [])
1163
+ note = (
1164
+ '<p class="api-section-intro">Generated by the build; not part of the repository.</p>'
1165
+ if isinstance(generated, list) and source_path in generated
1166
+ else ""
1167
+ )
1168
+ lines = [
1169
+ f"<h1>File: {html.escape(display_path)}</h1>" + note,
1170
+ f'<pre class="api-source"><code class="language-{html.escape(language)}">',
1171
+ ]
1172
+ for number, value in enumerate(highlighted, 1):
1173
+ lines.append(
1174
+ f'<span class="source-line" id="L{number}"><a class="source-number" href="#L{number}">{number}</a><span class="source-code">{value}</span></span>'
1175
+ )
1176
+ lines.extend(["</code></pre>"])
1177
+ return "".join(lines), []
1178
+
1179
+
1180
+ # ---------------------------------------------------------------------------
1181
+ # Complete layout and assets
1182
+ # ---------------------------------------------------------------------------
1183
+
1184
+
1185
+ def _page_title(entity: ApiEntity | None, graph: ApiGraph, special: str) -> str:
1186
+ if entity is not None:
1187
+ return f"{entity.name} — {graph.project} API documentation"
1188
+ if special == "variants":
1189
+ return f"API Variants — {graph.project} API documentation"
1190
+ if special == "macros":
1191
+ return f"Macros — {graph.project} API documentation"
1192
+ return f"{graph.project} API documentation"
1193
+
1194
+
1195
+ def _project_docs_href(document: Path, project_docs_url: str) -> str:
1196
+ if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", project_docs_url) or project_docs_url.startswith(
1197
+ "/"
1198
+ ):
1199
+ return project_docs_url
1200
+ root = _root_relative(document, ".")
1201
+ if root == ".":
1202
+ root = ""
1203
+ elif not root.endswith("/"):
1204
+ root += "/"
1205
+ return posixpath.normpath(root + project_docs_url).rstrip("/") + "/"
1206
+
1207
+
1208
+ def _layout(
1209
+ *,
1210
+ graph: ApiGraph,
1211
+ document: Path,
1212
+ body: str,
1213
+ toc: list[tuple[str, str, int]],
1214
+ current: ApiEntity | None,
1215
+ special: str,
1216
+ project_docs_url: str,
1217
+ copyright_text: str,
1218
+ ) -> str:
1219
+ body = _resolve_projectdocs_links(body, document, project_docs_url)
1220
+ # The version query changes whenever the asset does, so a browser never pairs new pages with a
1221
+ # stylesheet or script it cached from an older cpdocs.
1222
+ stylesheet = _root_relative(document, "assets/cpdocs-api.css") + f"?v={_asset_version('css')}"
1223
+ script = _root_relative(document, "assets/cpdocs-api.js") + f"?v={_asset_version('js')}"
1224
+ home = _page_href(document, Path("index.md"))
1225
+ project_docs = _project_docs_href(document, project_docs_url)
1226
+ version_root = _root_relative(document, ".")
1227
+ if version_root == ".":
1228
+ version_root = "./"
1229
+ elif not version_root.endswith("/"):
1230
+ version_root += "/"
1231
+ api_root = posixpath.normpath(version_root + "../").rstrip("/") + "/"
1232
+ current_route = _output_directory(document).as_posix().strip(".")
1233
+ if current_route and not current_route.endswith("/"):
1234
+ current_route += "/"
1235
+ return f'''<!doctype html>
1236
+ <html lang="en" data-theme="light">
1237
+ <head>
1238
+ <meta charset="utf-8">
1239
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1240
+ <meta name="color-scheme" content="light dark">
1241
+ <script>document.documentElement.classList.add("js");try{{for(const k of ["sidebar","toc"]){{const v=localStorage.getItem("cpdocs-api-"+k+"-width");if(v)document.documentElement.style.setProperty("--api-"+k+"-width",v+"px")}}}}catch(e){{}}try{{const t=localStorage.getItem("cpdocs-api-theme");document.documentElement.dataset.theme=t==="dark"||t==="light"?t:(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light")}}catch(e){{}}</script>
1242
+ <title>{html.escape(_page_title(current, graph, special))}</title>
1243
+ <link rel="stylesheet" href="{html.escape(stylesheet)}">
1244
+ </head>
1245
+ <body data-version-root="{html.escape(version_root)}" data-api-root="{html.escape(api_root)}" data-current-route="{html.escape(current_route)}">
1246
+ <header class="api-topbar">
1247
+ <a class="api-brand" href="{html.escape(home)}">{html.escape(graph.project)} API documentation</a>
1248
+ <div class="api-topbar-actions">
1249
+ <div class="api-search"><span aria-hidden="true">⌕</span><input type="search" placeholder="Search" aria-label="Search API"><div class="api-search-results" hidden></div></div>
1250
+ <a class="api-project-docs" href="{html.escape(project_docs)}" title="Project documentation"><span aria-hidden="true">←</span><span class="api-long"> Project documentation</span><span class="api-short"> Docs</span></a>
1251
+ <select class="api-version" aria-label="API version"><option>{html.escape(graph.version)}</option></select>
1252
+ <button class="api-theme" type="button" aria-label="Toggle color theme">◐</button>
1253
+ </div>
1254
+ </header>
1255
+ <div class="api-shell">
1256
+ <aside class="api-sidebar">{_outline(graph, document, current, current_special=special)}</aside>
1257
+ <main class="api-main">{body}</main>
1258
+ {_toc(toc)}
1259
+ <div class="api-resizer api-resizer-sidebar" data-resize="sidebar" role="separator" aria-orientation="vertical" aria-label="Resize the outline" title="Drag to resize, double-click to reset" tabindex="0"></div>
1260
+ <div class="api-resizer api-resizer-toc" data-resize="toc" role="separator" aria-orientation="vertical" aria-label="Resize the page contents" title="Drag to resize, double-click to reset" tabindex="0"></div>
1261
+ </div>
1262
+ <footer class="api-footer"><span>{html.escape(copyright_text)}</span><span>Generated by cpdocs.</span></footer>
1263
+ <script src="{html.escape(script)}"></script>
1264
+ </body>
1265
+ </html>
1266
+ '''
1267
+
1268
+
1269
+ _CSS = r"""
1270
+ :root {
1271
+ --api-accent: #2f62b5;
1272
+ --api-accent-2: #168b98;
1273
+ --api-bg: #ffffff;
1274
+ --api-fg: #20242a;
1275
+ --api-muted: #66707c;
1276
+ --api-border: #d8dde3;
1277
+ --api-soft: #f5f6f8;
1278
+ --api-selected: #eef1fb;
1279
+ --api-code: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace;
1280
+ --api-ui: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1281
+ --api-tree-indent: 2.45rem;
1282
+ }
1283
+ html[data-theme="dark"] {
1284
+ --api-accent: #83b7ff;
1285
+ --api-accent-2: #68d3dd;
1286
+ --api-bg: #17191c;
1287
+ --api-fg: #e8eaf0;
1288
+ --api-muted: #a9b1bc;
1289
+ --api-border: #3b4048;
1290
+ --api-soft: #22262b;
1291
+ --api-selected: #29334a;
1292
+ }
1293
+ * { box-sizing: border-box; }
1294
+ html, body { margin: 0; min-height: 100%; background: var(--api-bg); color: var(--api-fg); font-family: var(--api-ui); }
1295
+ a { color: var(--api-accent); text-decoration: none; }
1296
+ a:hover { text-decoration: underline; }
1297
+ code, pre { font-family: var(--api-code); }
1298
+ .api-topbar { height: 3rem; position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0 .9rem; border-bottom: 1px solid var(--api-border); background: var(--api-bg); }
1299
+ .api-brand { color: var(--api-fg); font-weight: 650; white-space: nowrap; }
1300
+ .api-topbar-actions { display: flex; align-items: center; gap: .7rem; margin-left: auto; }
1301
+ .api-search { position: relative; display: flex; align-items: center; gap: .4rem; border: 1px solid var(--api-border); border-radius: .4rem; padding: .25rem .55rem; min-width: 12rem; }
1302
+ .api-search input { border: 0; outline: 0; background: transparent; color: var(--api-fg); width: 100%; }
1303
+ .api-search-results { position: absolute; top: calc(100% + .3rem); right: 0; width: 26rem; max-height: 24rem; overflow: auto; background: var(--api-bg); border: 1px solid var(--api-border); border-radius: .35rem; box-shadow: 0 .4rem 1rem #0002; padding: .25rem; }
1304
+ .api-search-results a { display: block; padding: .35rem .45rem; border-radius: .25rem; }
1305
+ .api-search-results a:hover { background: var(--api-soft); text-decoration: none; }
1306
+ .api-project-docs .api-short { display: none; }
1307
+ .api-outline-panel-toggle { display: none; }
1308
+ .api-version { min-width: 6.5rem; }
1309
+ .api-feature-axis th { padding-top: .8rem; color: var(--api-muted); font-weight: 650; text-align: left; }
1310
+ tbody th[scope="row"] { font-weight: 400; text-align: left; }
1311
+ .api-doc-field { margin-top: .9rem; }
1312
+ .api-description .api-doc-field-title { margin: 0 0 .35rem; color: var(--api-muted); font-size: .78rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
1313
+ .api-doc-field dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .3rem 1.1rem; margin: 0; }
1314
+ .api-doc-field dt { margin: 0; font-weight: 700; }
1315
+ .api-doc-field dt code { color: var(--api-accent); }
1316
+ .api-doc-field dd { margin: 0; }
1317
+ .api-doc-field > p:not(.api-doc-field-title) { margin: 0; }
1318
+ .api-variant-tag { margin-left: .45rem; padding: .02rem .38rem; border: 1px solid var(--api-border); border-radius: .7rem; color: var(--api-muted); font-size: .72rem; white-space: nowrap; }
1319
+ .api-project-docs { color: #fff; background: var(--api-accent); border-radius: .25rem; padding: .38rem .65rem; font-weight: 650; white-space: nowrap; }
1320
+ .api-version, .api-theme { height: 2rem; border: 1px solid var(--api-border); border-radius: .3rem; background: var(--api-bg); color: var(--api-fg); }
1321
+ .api-theme { width: 2rem; cursor: pointer; }
1322
+ :root { --api-sidebar-default: 20.5rem; --api-toc-default: 24rem; }
1323
+ .api-shell { position: relative; display: grid; grid-template-columns: var(--api-sidebar-width, var(--api-sidebar-default)) minmax(0, 1fr) var(--api-toc-width, var(--api-toc-default)); min-height: calc(100vh - 5rem); }
1324
+ .api-resizer { position: absolute; top: 0; bottom: 0; z-index: 15; width: 9px; cursor: col-resize; touch-action: none; outline: none; }
1325
+ .api-resizer::after { content: ""; position: absolute; top: 0; bottom: 0; left: 3px; width: 3px; background: transparent; transition: background .12s; }
1326
+ .api-resizer:hover::after, .api-resizer:focus-visible::after, .is-resizing .api-resizer::after { background: var(--api-accent); }
1327
+ .api-resizer-sidebar { left: calc(var(--api-sidebar-width, var(--api-sidebar-default)) - 5px); }
1328
+ .api-resizer-toc { right: calc(var(--api-toc-width, var(--api-toc-default)) - 5px); }
1329
+ html.is-resizing, html.is-resizing * { cursor: col-resize !important; user-select: none; }
1330
+ .api-sidebar, .api-toc { position: sticky; top: 3rem; align-self: start; height: calc(100vh - 3rem); overflow: auto; padding: 1.15rem .8rem 2rem; }
1331
+ .api-sidebar { border-right: 1px solid var(--api-border); }
1332
+ .api-toc { border-left: 1px solid var(--api-border); }
1333
+ .api-main { width: min(80%, 80rem); min-width: 0; justify-self: center; padding: 1.45rem 0 4rem; }
1334
+ .api-main h1 { font-size: 1.75rem; line-height: 1.15; border-bottom: 2px solid var(--api-accent); padding-bottom: .25rem; margin: 0 0 .65rem; }
1335
+ .api-main h2 { font-size: 1.28rem; line-height: 1.2; border-left: .28rem solid var(--api-accent-2); padding-left: .55rem; margin: 1.2rem 0 .55rem; }
1336
+ .api-main h3 { font-size: 1.04rem; margin: .85rem 0 .35rem; }
1337
+ .api-main p, .api-main li, .api-main td, .api-main th { line-height: 1.34; }
1338
+ .api-main p { margin: .4rem 0; }
1339
+ .api-defined { margin: -.1rem 0 .75rem !important; }
1340
+ .api-sidebar h2, .api-toc h2 { font-size: 1.18rem; margin: .15rem 0 .65rem; }
1341
+ .api-outline-root, .api-outline-children { list-style: none; margin: 0; padding: 0; }
1342
+ .api-outline-root > li { margin: 0; }
1343
+ .api-outline-row { display: grid; grid-template-columns: 1.45rem max-content 1.4rem; column-gap: .68rem; align-items: center; width: fit-content; min-height: 1.72rem; max-width: 100%; }
1344
+ .api-outline-row.is-current { background: var(--api-selected); border-radius: .25rem; padding-right: .18rem; }
1345
+ .api-outline-link { color: var(--api-fg); padding: .1rem 0; white-space: nowrap; }
1346
+ .api-outline-row.is-current .api-outline-link { color: var(--api-accent); font-weight: 650; }
1347
+ .api-outline-root > li > .api-outline-row .api-outline-link { font-weight: 500; }
1348
+ .api-outline-root > li > .api-outline-row .api-outline-link[href*="api/"] { font-weight: 700; }
1349
+ .api-outline-children { margin: .08rem 0 .12rem var(--api-tree-indent); padding-left: 1.15rem; }
1350
+ .api-outline-children > li { position: relative; }
1351
+ .api-outline-children > li::before { content: ""; position: absolute; left: -1.15rem; top: -.08rem; bottom: -.12rem; border-left: 1px solid var(--api-border); }
1352
+ .api-outline-children > li::after { content: ""; position: absolute; left: -1.15rem; top: .85rem; width: 1rem; border-top: 1px solid var(--api-border); }
1353
+ .api-outline-children > li:last-child::before { bottom: auto; height: .94rem; }
1354
+ .api-kind { display: inline-grid; place-items: center; min-width: 1.42rem; height: 1.25rem; padding: 0 .22rem; border: 1px solid var(--api-border); border-radius: .23rem; color: var(--api-muted); font-family: var(--api-code); font-size: .68rem; line-height: 1; font-weight: 950; }
1355
+ .api-kind-empty { border-color: transparent; }
1356
+ .api-outline-toggle { position: relative; width: 1.4rem; height: 1.4rem; border: 1px solid var(--api-border); border-radius: .25rem; background: transparent; color: var(--api-muted); font: 800 1rem/1 var(--api-code); cursor: pointer; }
1357
+ .api-outline-toggle::before { content: "+"; position: absolute; inset: 0; display: grid; place-items: center; }
1358
+ .api-outline-toggle[aria-expanded="true"]::before { content: "−"; }
1359
+ .api-outline-toggle:hover { color: var(--api-accent); border-color: var(--api-accent); }
1360
+ .api-outline-children[hidden] { display: none; }
1361
+ .api-toc ul { list-style: none; padding: 0; margin: 0; }
1362
+ .api-toc li { margin: .34rem 0; }
1363
+ .api-toc .toc-level-2 { padding-left: .85rem; font-size: .9rem; }
1364
+ .api-toc a { color: var(--api-muted); }
1365
+ .api-toc a:hover { color: var(--api-accent); }
1366
+ .api-section { margin-top: 1rem; }
1367
+ .api-legend { display: flex; flex-wrap: wrap; align-items: center; gap: .28rem; margin: .25rem 0 .55rem; font-size: .83rem; }
1368
+ .api-legend-entry { display: inline-flex; align-items: center; gap: .28rem; }
1369
+ .api-legend-separator { color: var(--api-muted); }
1370
+ .api-member-list, .api-hierarchy, .api-hierarchy ul, .api-file-tree { list-style: none; margin: .25rem 0; padding-left: 1.25rem; }
1371
+ .api-member-list { padding-left: 0; }
1372
+ .api-member-list li { display: flex; align-items: center; gap: .55rem; padding: .24rem 0; border-bottom: 1px solid var(--api-border); }
1373
+ .api-namespace-tree, .api-namespace-tree ul { list-style: none; margin: .2rem 0; padding-left: 0; }
1374
+ .api-namespace-tree ul { margin-left: 2.35rem; padding-left: 1.05rem; border-left: 1px solid var(--api-border); }
1375
+ .api-namespace-tree li { margin: 0; }
1376
+ .api-namespace-tree-row { display: flex; align-items: center; gap: .52rem; min-height: 1.72rem; border-bottom: 1px solid var(--api-border); }
1377
+ .api-namespace-tree ul > li > .api-namespace-tree-row { position: relative; }
1378
+ .api-namespace-tree ul > li > .api-namespace-tree-row::before { content: ""; position: absolute; left: -1.05rem; width: .8rem; border-top: 1px solid var(--api-border); }
1379
+ .api-hierarchy li, .api-file-tree li { margin: .18rem 0; }
1380
+ /* Give the in-page API and file hierarchies the same tree connectors as the primary outline. */
1381
+ .api-hierarchy ul,
1382
+ .api-file-tree .api-file-tree {
1383
+ margin-left: .35rem;
1384
+ padding-left: 1.15rem;
1385
+ }
1386
+ .api-hierarchy ul > li,
1387
+ .api-file-tree .api-file-tree > li {
1388
+ position: relative;
1389
+ }
1390
+ .api-hierarchy ul > li::before,
1391
+ .api-file-tree .api-file-tree > li::before {
1392
+ border-left: 1px solid var(--api-border);
1393
+ bottom: -.18rem;
1394
+ content: "";
1395
+ left: -1.15rem;
1396
+ position: absolute;
1397
+ top: -.18rem;
1398
+ }
1399
+ .api-hierarchy ul > li::after,
1400
+ .api-file-tree .api-file-tree > li::after {
1401
+ border-top: 1px solid var(--api-border);
1402
+ content: "";
1403
+ left: -1.15rem;
1404
+ position: absolute;
1405
+ top: .72rem;
1406
+ width: .9rem;
1407
+ }
1408
+ .api-hierarchy ul > li:last-child::before,
1409
+ .api-file-tree .api-file-tree > li:last-child::before {
1410
+ bottom: auto;
1411
+ height: .9rem;
1412
+ }
1413
+ .api-hierarchy .api-kind { margin-right: .35rem; }
1414
+ .api-file-tree .api-kind { margin-right: .35rem; }
1415
+ .api-entity-card, .api-signature-card { border: 1px solid var(--api-border); border-top: .22rem solid var(--api-accent); border-radius: .45rem; padding: .7rem .85rem .8rem; margin: .55rem 0 .9rem; }
1416
+ .api-signature { margin: 0; padding: 0 0 .55rem; background: transparent; border-bottom: 1px solid var(--api-border); overflow-x: auto; font-size: .87rem; line-height: 1.35; font-weight: 650; }
1417
+ .api-signature code { padding: 0; }
1418
+ .api-signature-name { font-weight: 800; }
1419
+ .api-meta-row { display: flex; align-items: baseline; gap: .65rem; margin-top: .42rem; font-size: .78rem; }
1420
+ .api-meta-row > strong { flex: 0 0 5rem; }
1421
+ .api-badges { display: flex; flex-wrap: wrap; gap: .3rem; }
1422
+ .api-badges > span { border: 1px solid color-mix(in srgb, var(--api-accent) 32%, var(--api-border)); border-radius: 999px; background: color-mix(in srgb, var(--api-accent) 8%, transparent); padding: .08rem .36rem; font: 650 .72rem var(--api-code); }
1423
+ .api-description { border-top: 1px solid var(--api-border); margin-top: .55rem; padding-top: .45rem; }
1424
+ .api-related { margin-top: .45rem !important; }
1425
+ .api-availability { display: flex; align-items: baseline; gap: .7rem; border-top: 1px solid var(--api-border); margin-top: .55rem; padding-top: .48rem; font-size: .82rem; }
1426
+ .api-availability > strong { flex: 0 0 5rem; }
1427
+ .api-record-members { border-top: 1px solid var(--api-border); }
1428
+ .api-member-row { display: grid; grid-template-columns: 1.5rem minmax(0, 1fr); align-items: baseline; gap: .55rem; border-bottom: 1px solid var(--api-border); padding: .34rem .08rem; }
1429
+ .api-member-row code { font-weight: 600; }
1430
+ .api-member-doc { grid-column: 2; color: var(--api-muted); }
1431
+ .api-namespace-doc { margin: .4rem 0 .8rem; }
1432
+ table { width: 100%; border-collapse: collapse; margin: .4rem 0 .8rem; }
1433
+ th, td { text-align: left; padding: .45rem .55rem; border-bottom: 1px solid var(--api-border); }
1434
+ thead th { border-bottom: 2px solid var(--api-accent); }
1435
+ tbody tr:nth-child(odd) { background: var(--api-soft); }
1436
+ .table-scroll { overflow-x: auto; }
1437
+ .api-section-intro { color: var(--api-muted); max-width: 70rem; }
1438
+ .api-variant-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: .75rem; margin: .7rem 0 1rem; }
1439
+ .api-variant-card { border: 1px solid var(--api-border); border-top: .2rem solid var(--api-accent-2); border-radius: .45rem; background: color-mix(in srgb, var(--api-soft) 55%, transparent); overflow: hidden; }
1440
+ .api-variant-card header { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: .62rem .75rem .5rem; border-bottom: 1px solid var(--api-border); }
1441
+ .api-variant-card h3 { margin: 0; font-size: 1rem; }
1442
+ .api-variant-card-label { color: var(--api-muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; }
1443
+ .api-variant-card dl { margin: 0; padding: .2rem .75rem .65rem; }
1444
+ .api-variant-card dl > div { padding: .45rem 0; }
1445
+ .api-variant-card dl > div + div { border-top: 1px solid var(--api-border); }
1446
+ .api-variant-card dt { font-size: .76rem; font-weight: 700; color: var(--api-muted); margin-bottom: .22rem; }
1447
+ .api-variant-card dd { margin: 0; display: flex; flex-wrap: wrap; gap: .28rem; }
1448
+ .api-chip { display: inline-block; border: 1px solid var(--api-border); border-radius: .25rem; background: var(--api-bg); padding: .08rem .32rem; }
1449
+ .api-none { color: var(--api-muted); font-style: italic; }
1450
+ .api-source { background: var(--api-soft); border: 1px solid var(--api-border); border-radius: .4rem; padding: .75rem 0; overflow: auto; font-size: .82rem; line-height: 1.35; }
1451
+ .source-line { display: grid; grid-template-columns: 4.2rem minmax(max-content, 1fr); min-height: 1.1rem; }
1452
+ .source-line:target { background: color-mix(in srgb, var(--api-accent) 12%, transparent); }
1453
+ .source-number { user-select: none; text-align: right; padding-right: 1rem; color: var(--api-muted); }
1454
+ .source-code { white-space: pre; padding-right: 1rem; }
1455
+ .cpdocs-syntax-keyword { color: #005cc5; font-weight: 650; }
1456
+ .cpdocs-syntax-comment { color: #6a737d; font-style: italic; }
1457
+ .cpdocs-syntax-string { color: #032f62; }
1458
+ .cpdocs-syntax-number { color: #005cc5; }
1459
+ .cpdocs-syntax-preprocessor { color: #6f42c1; }
1460
+ html[data-theme="dark"] .cpdocs-syntax-keyword { color: #79b8ff; }
1461
+ html[data-theme="dark"] .cpdocs-syntax-comment { color: #8b949e; }
1462
+ html[data-theme="dark"] .cpdocs-syntax-string { color: #9ecbff; }
1463
+ html[data-theme="dark"] .cpdocs-syntax-number { color: #79b8ff; }
1464
+ html[data-theme="dark"] .cpdocs-syntax-preprocessor { color: #d2a8ff; }
1465
+ .api-footer { min-height: 2rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; border-top: 1px solid var(--api-border); padding: .45rem 1rem; font-size: .72rem; color: var(--api-muted); }
1466
+ @media (max-width: 1400px) { :root { --api-sidebar-default: 19rem; --api-toc-default: 20rem; } .api-main { width: 88%; } }
1467
+ @media (max-width: 1100px) { :root { --api-sidebar-default: 18rem; } .api-shell { grid-template-columns: var(--api-sidebar-width, var(--api-sidebar-default)) minmax(0, 1fr); } .api-toc, .api-resizer-toc { display: none; } .api-main { width: 92%; } }
1468
+ @media (max-width: 760px) { .api-shell { display: block; } .api-resizer { display: none; } .api-sidebar { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--api-border); } .api-topbar-actions { gap: .35rem; } .api-search { min-width: 7rem; } .api-project-docs { padding: .38rem .5rem; } .api-project-docs .api-long { display: none; } .api-project-docs .api-short { display: inline; } .api-topbar { height: auto; min-height: 3rem; flex-wrap: wrap; row-gap: .4rem; padding: .45rem .9rem; } .api-brand { white-space: normal; } .api-main { width: auto; padding: 1rem; } .api-sidebar { padding: .5rem .8rem; } .api-sidebar h2 { display: none; } .api-outline-panel-toggle { display: flex; align-items: center; gap: .6rem; width: 100%; min-height: 2.4rem; padding: .35rem .6rem; border: 1px solid var(--api-border); border-radius: .35rem; background: var(--api-soft); color: var(--api-fg); font: inherit; font-weight: 650; cursor: pointer; text-align: left; } .api-outline-panel-toggle::after { content: "▾"; margin-left: auto; transition: transform .15s; } .api-outline-panel-toggle[aria-expanded="true"]::after { transform: rotate(180deg); } .api-outline-current { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--api-muted); font-weight: 500; } .js .api-sidebar:not(.is-open) .api-outline-root { display: none; } .api-sidebar.is-open .api-outline-root { max-height: 60vh; overflow: auto; margin-top: .5rem; } }
1469
+ """
1470
+
1471
+
1472
+ _JS = r"""
1473
+ (() => {
1474
+ const root = document.documentElement;
1475
+
1476
+ // Keep the sidebar where the reader left it; a full page load otherwise jumps it to the top.
1477
+ const sidebar = document.querySelector(".api-sidebar");
1478
+ const panelToggle = document.querySelector(".api-outline-panel-toggle");
1479
+ panelToggle?.addEventListener("click", () => {
1480
+ const open = sidebar?.classList.toggle("is-open") ?? false;
1481
+ panelToggle.setAttribute("aria-expanded", open ? "true" : "false");
1482
+ if (open) sidebar?.querySelector(".is-current")?.scrollIntoView({block: "nearest"});
1483
+ });
1484
+ if (sidebar) {
1485
+ try {
1486
+ const saved = sessionStorage.getItem("cpdocs-api-sidebar");
1487
+ if (saved !== null) sidebar.scrollTop = Number(saved);
1488
+ } catch {}
1489
+ const currentRow = sidebar.querySelector(".is-current");
1490
+ if (currentRow) {
1491
+ const box = sidebar.getBoundingClientRect();
1492
+ const row = currentRow.getBoundingClientRect();
1493
+ if (row.top < box.top || row.bottom > box.bottom) currentRow.scrollIntoView({block: "center"});
1494
+ }
1495
+ addEventListener("pagehide", () => {
1496
+ try { sessionStorage.setItem("cpdocs-api-sidebar", String(sidebar.scrollTop)); } catch {}
1497
+ });
1498
+ }
1499
+
1500
+ document.querySelector(".api-theme")?.addEventListener("click", () => {
1501
+ const next = root.dataset.theme === "dark" ? "light" : "dark";
1502
+ root.dataset.theme = next;
1503
+ localStorage.setItem("cpdocs-api-theme", next);
1504
+ });
1505
+
1506
+ // Drag the border of the outline or of the page contents to resize it; the width is kept for
1507
+ // later visits. Arrow keys resize a focused handle, double-click restores the default.
1508
+ for (const handle of document.querySelectorAll(".api-resizer")) {
1509
+ const side = handle.dataset.resize;
1510
+ const panel = document.querySelector(side === "sidebar" ? ".api-sidebar" : ".api-toc");
1511
+ const shell = handle.closest(".api-shell");
1512
+ if (!panel || !shell) continue;
1513
+ const property = `--api-${side}-width`;
1514
+ const key = `cpdocs-api-${side}-width`;
1515
+ const clamp = value => Math.round(Math.min(Math.max(value, 160), window.innerWidth * 0.45));
1516
+ const apply = width => root.style.setProperty(property, `${width}px`);
1517
+ const save = () => {
1518
+ try { localStorage.setItem(key, String(Math.round(panel.getBoundingClientRect().width))); } catch {}
1519
+ };
1520
+ handle.addEventListener("pointerdown", event => {
1521
+ if (event.button !== 0) return;
1522
+ event.preventDefault();
1523
+ handle.setPointerCapture(event.pointerId);
1524
+ root.classList.add("is-resizing");
1525
+ const box = shell.getBoundingClientRect();
1526
+ const move = moving => apply(clamp(side === "sidebar" ? moving.clientX - box.left : box.right - moving.clientX));
1527
+ const stop = () => {
1528
+ handle.removeEventListener("pointermove", move);
1529
+ handle.removeEventListener("pointerup", stop);
1530
+ handle.removeEventListener("pointercancel", stop);
1531
+ root.classList.remove("is-resizing");
1532
+ save();
1533
+ };
1534
+ handle.addEventListener("pointermove", move);
1535
+ handle.addEventListener("pointerup", stop);
1536
+ handle.addEventListener("pointercancel", stop);
1537
+ });
1538
+ handle.addEventListener("keydown", event => {
1539
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
1540
+ event.preventDefault();
1541
+ const step = (event.shiftKey ? 64 : 16) * (event.key === "ArrowRight" ? 1 : -1);
1542
+ apply(clamp(panel.getBoundingClientRect().width + (side === "sidebar" ? step : -step)));
1543
+ save();
1544
+ });
1545
+ handle.addEventListener("dblclick", () => {
1546
+ root.style.removeProperty(property);
1547
+ try { localStorage.removeItem(key); } catch {}
1548
+ });
1549
+ }
1550
+
1551
+ for (const button of document.querySelectorAll(".api-outline-toggle")) {
1552
+ const row = button.closest(".api-outline-row");
1553
+ const children = row?.nextElementSibling;
1554
+ if (!children?.classList.contains("api-outline-children")) continue;
1555
+ button.addEventListener("click", () => {
1556
+ const expanded = button.getAttribute("aria-expanded") === "true";
1557
+ button.setAttribute("aria-expanded", expanded ? "false" : "true");
1558
+ children.hidden = expanded;
1559
+ });
1560
+ }
1561
+
1562
+ const version = document.querySelector(".api-version");
1563
+ const apiRoot = document.body.dataset.apiRoot || "../";
1564
+ const route = document.body.dataset.currentRoute || "";
1565
+ if (version) {
1566
+ fetch(apiRoot + "versions.json")
1567
+ .then(response => response.ok ? response.json() : null)
1568
+ .then(data => {
1569
+ if (!data?.versions) return;
1570
+ version.replaceChildren();
1571
+ for (const item of data.versions) {
1572
+ const option = document.createElement("option");
1573
+ option.value = item.name;
1574
+ option.textContent = item.name;
1575
+ option.selected = item.name === document.querySelector(".api-version")?.dataset.current;
1576
+ version.appendChild(option);
1577
+ }
1578
+ const current = location.pathname.split("/").filter(Boolean).at(-1);
1579
+ for (const option of version.options) {
1580
+ if (location.pathname.includes(`/${option.value}/`)) option.selected = true;
1581
+ }
1582
+ version.addEventListener("change", () => {
1583
+ location.href = apiRoot + version.value + "/" + route;
1584
+ });
1585
+ }).catch(() => {});
1586
+ }
1587
+
1588
+ const search = document.querySelector(".api-search input");
1589
+ const results = document.querySelector(".api-search-results");
1590
+ const versionRoot = document.body.dataset.versionRoot || "./";
1591
+ let index = null;
1592
+ function showResults(values) {
1593
+ if (!results) return;
1594
+ results.replaceChildren();
1595
+ for (const item of values.slice(0, 20)) {
1596
+ const link = document.createElement("a");
1597
+ link.href = versionRoot + item.url;
1598
+ link.innerHTML = `<strong>${item.label}</strong><br><small>${item.kind} · ${item.qualified}</small>`;
1599
+ results.appendChild(link);
1600
+ }
1601
+ results.hidden = values.length === 0;
1602
+ }
1603
+ search?.addEventListener("input", async () => {
1604
+ const query = search.value.trim().toLowerCase();
1605
+ if (!query) { if (results) results.hidden = true; return; }
1606
+ if (index === null) {
1607
+ try { index = await (await fetch(versionRoot + "search-index.json")).json(); }
1608
+ catch { index = []; }
1609
+ }
1610
+ const values = index.filter(item => `${item.label} ${item.qualified}`.toLowerCase().includes(query));
1611
+ showResults(values);
1612
+ });
1613
+ document.addEventListener("click", event => {
1614
+ if (!event.target.closest(".api-search") && results) results.hidden = true;
1615
+ });
1616
+ })();
1617
+ """
1618
+
1619
+
1620
+ @functools.cache
1621
+ def _asset_version(kind: str) -> str:
1622
+ text = _CSS if kind == "css" else _JS
1623
+ return hashlib.sha1(text.strip().encode("utf-8")).hexdigest()[:10]
1624
+
1625
+
1626
+ def _write_page(
1627
+ *,
1628
+ output: Path,
1629
+ graph: ApiGraph,
1630
+ document: Path,
1631
+ body: str,
1632
+ toc: list[tuple[str, str, int]],
1633
+ current: ApiEntity | None,
1634
+ special: str,
1635
+ project_docs_url: str,
1636
+ copyright_text: str,
1637
+ ) -> None:
1638
+ path = output / _output_file(document)
1639
+ path.parent.mkdir(parents=True, exist_ok=True)
1640
+ path.write_text(
1641
+ _layout(
1642
+ graph=graph,
1643
+ document=document,
1644
+ body=body,
1645
+ toc=toc,
1646
+ current=current,
1647
+ special=special,
1648
+ project_docs_url=project_docs_url,
1649
+ copyright_text=copyright_text,
1650
+ ),
1651
+ encoding="utf-8",
1652
+ )
1653
+
1654
+
1655
+ def _redirect(output: Path, document: Path, target: str) -> None:
1656
+ path = output / _output_file(document)
1657
+ path.parent.mkdir(parents=True, exist_ok=True)
1658
+ path.write_text(
1659
+ '<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="0; url='
1660
+ + html.escape(target, quote=True)
1661
+ + '">'
1662
+ "<script>location.replace(" + json.dumps(target) + ")</script>",
1663
+ encoding="utf-8",
1664
+ )
1665
+
1666
+
1667
+ def _search_index(graph: ApiGraph) -> list[dict[str, str]]:
1668
+ values: list[dict[str, str]] = []
1669
+ for entity in sorted(
1670
+ graph.entities.values(), key=lambda value: value.qualified_name.casefold()
1671
+ ):
1672
+ target, anchor = _entity_target(entity, graph)
1673
+ url = _output_directory(target).as_posix().rstrip("/") + "/"
1674
+ if anchor:
1675
+ url += f"#{anchor}"
1676
+ values.append(
1677
+ {
1678
+ "label": entity.member_label,
1679
+ "qualified": entity.qualified_name,
1680
+ "kind": _kind_label(entity.kind),
1681
+ "url": url,
1682
+ }
1683
+ )
1684
+ return values
1685
+
1686
+
1687
+ def render_html_site(
1688
+ graph: ApiGraph,
1689
+ *,
1690
+ output_directory: Path,
1691
+ project_docs_url: str,
1692
+ copyright_text: str,
1693
+ introduction_file: Path | None = None,
1694
+ ) -> None:
1695
+ """Render one API version directly to a standalone HTML site."""
1696
+
1697
+ assign_documents(graph.entities.values())
1698
+ output = output_directory.resolve()
1699
+ shutil.rmtree(output, ignore_errors=True)
1700
+ output.mkdir(parents=True, exist_ok=True)
1701
+ assets = output / "assets"
1702
+ assets.mkdir(parents=True, exist_ok=True)
1703
+ (assets / "cpdocs-api.css").write_text(_CSS.strip() + "\n", encoding="utf-8")
1704
+ (assets / "cpdocs-api.js").write_text(_JS.strip() + "\n", encoding="utf-8")
1705
+
1706
+ home_document = Path("index.md")
1707
+ introduction = None
1708
+ if introduction_file is not None and introduction_file.is_file():
1709
+ introduction = introduction_file.read_text(encoding="utf-8")
1710
+ body, toc = _home_page(graph, home_document, project_docs_url, introduction=introduction)
1711
+ _write_page(
1712
+ output=output,
1713
+ graph=graph,
1714
+ document=home_document,
1715
+ body=body,
1716
+ toc=toc,
1717
+ current=None,
1718
+ special="home",
1719
+ project_docs_url=project_docs_url,
1720
+ copyright_text=copyright_text,
1721
+ )
1722
+
1723
+ if graph.variants:
1724
+ variants_document = Path("api-variants.md")
1725
+ body, toc = _variant_page(graph, variants_document)
1726
+ _write_page(
1727
+ output=output,
1728
+ graph=graph,
1729
+ document=variants_document,
1730
+ body=body,
1731
+ toc=toc,
1732
+ current=None,
1733
+ special="variants",
1734
+ project_docs_url=project_docs_url,
1735
+ copyright_text=copyright_text,
1736
+ )
1737
+
1738
+ if any(entity.kind == "macro" for entity in graph.entities.values()):
1739
+ macros_document = Path("macros.md")
1740
+ body, toc = _macros_page(graph, macros_document)
1741
+ _write_page(
1742
+ output=output,
1743
+ graph=graph,
1744
+ document=macros_document,
1745
+ body=body,
1746
+ toc=toc,
1747
+ current=None,
1748
+ special="macros",
1749
+ project_docs_url=project_docs_url,
1750
+ copyright_text=copyright_text,
1751
+ )
1752
+
1753
+ for entity in graph.entities.values():
1754
+ if _embedded_member_parent(entity, graph) is not None:
1755
+ continue
1756
+ document = entity_document(entity)
1757
+ body, toc = _entity_page(entity, graph, document)
1758
+ _write_page(
1759
+ output=output,
1760
+ graph=graph,
1761
+ document=document,
1762
+ body=body,
1763
+ toc=toc,
1764
+ current=entity,
1765
+ special="entity",
1766
+ project_docs_url=project_docs_url,
1767
+ copyright_text=copyright_text,
1768
+ )
1769
+
1770
+ for source_path, content in graph.sources.items():
1771
+ document = _source_document(source_path)
1772
+ body, toc = _source_page(source_path, content, document, graph)
1773
+ _write_page(
1774
+ output=output,
1775
+ graph=graph,
1776
+ document=document,
1777
+ body=body,
1778
+ toc=toc,
1779
+ current=None,
1780
+ special="source",
1781
+ project_docs_url=project_docs_url,
1782
+ copyright_text=copyright_text,
1783
+ )
1784
+
1785
+ symbols: dict[str, str] = {}
1786
+ for entity in graph.entities.values():
1787
+ target_document, anchor = _entity_target(entity, graph)
1788
+ target_url = _output_directory(target_document).as_posix().rstrip("/") + "/"
1789
+ if anchor:
1790
+ target_url += f"#{anchor}"
1791
+ symbols[entity.qualified_name] = target_url
1792
+ separator = "." if entity.language == "python" else "::"
1793
+ alias = Path("_symbols", *entity.qualified_name.split(separator), "index.md")
1794
+ redirect_target = _page_href(alias, target_document, anchor)
1795
+ _redirect(output, alias, redirect_target)
1796
+
1797
+ (output / "symbols.json").write_text(
1798
+ json.dumps(symbols, indent=2, sort_keys=True) + "\n", encoding="utf-8"
1799
+ )
1800
+ (output / "search-index.json").write_text(
1801
+ json.dumps(_search_index(graph), indent=2) + "\n", encoding="utf-8"
1802
+ )
1803
+ (output / ".nojekyll").touch()