markwork 0.1.0.dev0__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,620 @@
1
+ """
2
+ The 'SourceDocs' class drives a documentation run: it holds the symbol
3
+ registries and resolved paths, emits a page per source file, and writes
4
+ the go-to-definition fragments and cross-reference sections.
5
+ """
6
+ # Apache-2.0 license
7
+ # Copyright (c) 2026 Asger Jon Vistisen
8
+
9
+ import ast
10
+ import shutil
11
+ from pathlib import Path
12
+
13
+ from ._abs_module import absModule
14
+ from ._add_alias_site import addAliasSite
15
+ from ._bound_anywhere import boundAnywhere
16
+ from ._builtin_links import BUILTIN_LINKS
17
+ from ._highlight import highlightWithLinks
18
+ from ._is_stdlib import isStdlib
19
+ from ._line_starts import lineStarts
20
+ from ._module_definitions import moduleDefinitions
21
+ from ._nested_bound import nestedBound
22
+ from ._parse_init import parseInit
23
+ from ._pydocs import PYDOCS
24
+ from ._title import title
25
+ from ._top_level_def_lines import topLevelDefLines
26
+ from ._top_level_defs import topLevelDefs
27
+
28
+
29
+ class SourceDocs:
30
+ """A single documentation run over one package and its tests.
31
+
32
+ The instance owns four registries and five filesystem roots. The
33
+ registries are populated as pages are emitted, then read back when the
34
+ deferred fragments and the cross-reference sections are written, so a
35
+ link can target a symbol defined in a file emitted later in the run.
36
+
37
+ Parameters
38
+ ----------
39
+ repoRoot : Path
40
+ The project root, the parent of the docs directory.
41
+ srcRoot : Path
42
+ The source root the package lives under.
43
+ pkg : Path
44
+ The package directory to document.
45
+ testsRoot : Path
46
+ The tests directory to mirror.
47
+ out : Path
48
+ The output directory the generated rST and fragments go to.
49
+ """
50
+
51
+ def __init__(
52
+ self, repoRoot: Path, srcRoot: Path, pkg: Path, testsRoot: Path,
53
+ out: Path
54
+ ) -> None:
55
+ self.__repo_root__ = repoRoot
56
+ self.__src_root__ = srcRoot
57
+ self.__pkg__ = pkg
58
+ self.__tests_root__ = testsRoot
59
+ self.__out__ = out
60
+ # (absolute module dotted, name) -> page stub documenting the symbol.
61
+ self.__symbol_page__ = {}
62
+ # (absolute module dotted, name) -> (page stub, definition line).
63
+ self.__symbol_def__ = {}
64
+ # Source/test file (repo-relative posix) -> page stub.
65
+ self.__file_page__ = {}
66
+ # (source file, page stub) pairs whose fragment is rendered last, so
67
+ # a go-to-definition link can point at a file emitted later.
68
+ self.__pending_frags__ = []
69
+
70
+ # ----------------------------------------------------------
71
+ # Path / rST helpers
72
+ # ----------------------------------------------------------
73
+
74
+ def relPath(self, pyfile: Path) -> str:
75
+ """Return the repo-relative posix path of a file.
76
+
77
+ Parameters
78
+ ----------
79
+ pyfile : Path
80
+ The file to relativise.
81
+
82
+ Returns
83
+ -------
84
+ str
85
+ The path relative to the repo root, in posix form.
86
+ """
87
+ return pyfile.relative_to(self.__repo_root__).as_posix()
88
+
89
+ def srcDotted(self, path: Path) -> str:
90
+ """Return the dotted name of a path relative to the source root.
91
+
92
+ Parameters
93
+ ----------
94
+ path : Path
95
+ A source file or directory under the source root.
96
+
97
+ Returns
98
+ -------
99
+ str
100
+ The dotted name, for example 'markwork.engine', used for symbol
101
+ keys and source stub names.
102
+ """
103
+ rel = path.relative_to(self.__src_root__)
104
+ if path.suffix == ".py":
105
+ rel = rel.with_suffix("")
106
+ return ".".join(rel.parts)
107
+
108
+ def repoDotted(self, path: Path) -> str:
109
+ """Return the dotted name of a path relative to the repo root.
110
+
111
+ Parameters
112
+ ----------
113
+ path : Path
114
+ A file or directory under the repo root.
115
+
116
+ Returns
117
+ -------
118
+ str
119
+ The dotted name, for example 'tests.test_render', used for test
120
+ stub names so they stay distinct from source stubs.
121
+ """
122
+ rel = path.relative_to(self.__repo_root__)
123
+ if path.suffix == ".py":
124
+ rel = rel.with_suffix("")
125
+ return ".".join(rel.parts)
126
+
127
+ def write(self, stub: str, body: str) -> None:
128
+ """Write one stub file, named '<stub>.rst', into the output dir.
129
+
130
+ Parameters
131
+ ----------
132
+ stub : str
133
+ The page stub name.
134
+ body : str
135
+ The rST body, written with a trailing newline.
136
+ """
137
+ path = self.__out__ / ("%s.rst" % stub)
138
+ path.write_text(body + "\n", encoding="utf-8")
139
+
140
+ def recordFilePage(self, pyfile: Path, stub: str) -> None:
141
+ """Remember which page shows a given file, first page winning.
142
+
143
+ Parameters
144
+ ----------
145
+ pyfile : Path
146
+ The source file.
147
+ stub : str
148
+ The page stub showing it.
149
+ """
150
+ self.__file_page__.setdefault(self.relPath(pyfile), stub)
151
+
152
+ def renderSource(self, pyfile: Path, stub: str) -> str:
153
+ """Record a file for deferred fragment rendering and return the rST
154
+ that embeds the fragment.
155
+
156
+ The fragment is embedded via ':file:' so the '<pre>' content is not
157
+ re-indented by rST, and it is written later, once every page (hence
158
+ every definition target) is known.
159
+
160
+ Parameters
161
+ ----------
162
+ pyfile : Path
163
+ The source file to render later.
164
+ stub : str
165
+ The page stub the fragment belongs to.
166
+
167
+ Returns
168
+ -------
169
+ str
170
+ The rST embedding the fragment.
171
+ """
172
+ self.__pending_frags__.append((pyfile, stub))
173
+ return "\n".join(
174
+ [
175
+ "*%s*" % self.relPath(pyfile),
176
+ "",
177
+ ".. raw:: html",
178
+ " :file: %s.frag.html" % stub,
179
+ ]
180
+ )
181
+
182
+ # ----------------------------------------------------------
183
+ # Library page emission (driven by __all__)
184
+ # ----------------------------------------------------------
185
+
186
+ def emitObject(self, pkgDotted: str, public: str, pyfile: Path) -> str:
187
+ """Emit a leaf page for one public name, showing the whole file.
188
+
189
+ Parameters
190
+ ----------
191
+ pkgDotted : str
192
+ The dotted name of the owning package.
193
+ public : str
194
+ The public name being documented.
195
+ pyfile : Path
196
+ The file that defines it.
197
+
198
+ Returns
199
+ -------
200
+ str
201
+ The page stub.
202
+ """
203
+ stub = "%s.%s" % (pkgDotted, public)
204
+ self.write(
205
+ stub, "%s\n\n%s\n" % (title(public), self.renderSource(pyfile, stub))
206
+ )
207
+ self.recordFilePage(pyfile, stub)
208
+ return stub
209
+
210
+ def emitWholeFile(self, pyfile: Path) -> str:
211
+ """Emit a leaf page for a submodule pulled in via 'from . import X'.
212
+
213
+ Parameters
214
+ ----------
215
+ pyfile : Path
216
+ The submodule file.
217
+
218
+ Returns
219
+ -------
220
+ str
221
+ The page stub.
222
+ """
223
+ stub = self.srcDotted(pyfile)
224
+ self.write(
225
+ stub,
226
+ "%s\n\n%s\n" % (title(pyfile.stem), self.renderSource(pyfile, stub)),
227
+ )
228
+ self.recordFilePage(pyfile, stub)
229
+ return stub
230
+
231
+ def emitPackage(self, pkgDir: Path) -> str:
232
+ """Emit a package stub: its __init__ source plus a toctree of __all__.
233
+
234
+ Each public name is resolved: a child subpackage recurses, a child
235
+ module is shown whole, a re-exported symbol is documented from its
236
+ defining file, and an own definition is documented in place. Missing
237
+ or non-exported names produce nothing.
238
+
239
+ Parameters
240
+ ----------
241
+ pkgDir : Path
242
+ The package directory.
243
+
244
+ Returns
245
+ -------
246
+ str
247
+ The package page stub.
248
+ """
249
+ dotted = self.srcDotted(pkgDir)
250
+ init = pkgDir / "__init__.py"
251
+ allNames, sourceMap = parseInit(init)
252
+ ownDefs = topLevelDefs(init)
253
+ ownDefLines = topLevelDefLines(init)
254
+ children = []
255
+ for name in allNames:
256
+ entry = sourceMap.get(name)
257
+ if entry is None:
258
+ if name in ownDefs:
259
+ stub = self.emitObject(dotted, name, init)
260
+ self.__symbol_page__[(dotted, name)] = stub
261
+ self.__symbol_def__[(dotted, name)] = (
262
+ stub, ownDefLines.get(name, 1)
263
+ )
264
+ children.append(stub)
265
+ continue
266
+ if entry[0] == "child":
267
+ child = entry[1]
268
+ childDir = pkgDir / child
269
+ childFile = pkgDir / ("%s.py" % child)
270
+ if childDir.is_dir() and (childDir / "__init__.py").exists():
271
+ children.append(self.emitPackage(childDir))
272
+ elif childFile.exists():
273
+ children.append(self.emitWholeFile(childFile))
274
+ else:
275
+ module, origin = entry[1], entry[2]
276
+ modfile = pkgDir / ("%s.py" % module)
277
+ if modfile.exists():
278
+ stub = self.emitObject(dotted, name, modfile)
279
+ defLine = topLevelDefLines(modfile).get(origin, 1)
280
+ modKey = "%s.%s" % (dotted, module)
281
+ self.__symbol_page__[(dotted, name)] = stub
282
+ self.__symbol_page__[(modKey, origin)] = stub
283
+ self.__symbol_def__[(dotted, name)] = (stub, defLine)
284
+ self.__symbol_def__[(modKey, origin)] = (stub, defLine)
285
+ children.append(stub)
286
+ self.recordFilePage(init, dotted)
287
+ body = "%s\n\n%s\n\n.. toctree::\n :maxdepth: 1\n\n" % (
288
+ title(pkgDir.name), self.renderSource(init, dotted)
289
+ )
290
+ body += "\n".join(" %s" % child for child in children) + "\n"
291
+ self.write(dotted, body)
292
+ return dotted
293
+
294
+ # ----------------------------------------------------------
295
+ # Test tree emission (mirrors the directory layout)
296
+ # ----------------------------------------------------------
297
+
298
+ def emitTestFile(self, pyfile: Path) -> str:
299
+ """Emit a leaf page for one test file, shown whole.
300
+
301
+ Parameters
302
+ ----------
303
+ pyfile : Path
304
+ The test file.
305
+
306
+ Returns
307
+ -------
308
+ str
309
+ The page stub.
310
+ """
311
+ stub = self.repoDotted(pyfile)
312
+ self.write(
313
+ stub,
314
+ "%s\n\n%s\n" % (title(pyfile.stem), self.renderSource(pyfile, stub)),
315
+ )
316
+ self.recordFilePage(pyfile, stub)
317
+ return stub
318
+
319
+ def emitTestDir(self, testDir: Path) -> str:
320
+ """Emit a directory stub: a title and a toctree of its tests.
321
+
322
+ The toctree lists each test file and each populated subdirectory.
323
+ __pycache__, __init__.py and empty directories are skipped.
324
+
325
+ Parameters
326
+ ----------
327
+ testDir : Path
328
+ The test directory.
329
+
330
+ Returns
331
+ -------
332
+ str
333
+ The directory page stub.
334
+ """
335
+ stub = self.repoDotted(testDir)
336
+ children = []
337
+ for pyfile in sorted(testDir.glob("*.py")):
338
+ if pyfile.name != "__init__.py":
339
+ children.append(self.emitTestFile(pyfile))
340
+ for sub in sorted(p for p in testDir.iterdir() if p.is_dir()):
341
+ if sub.name == "__pycache__":
342
+ continue
343
+ if any(sub.rglob("*.py")):
344
+ children.append(self.emitTestDir(sub))
345
+ parts = [
346
+ title(testDir.name), "", ".. toctree::", " :maxdepth: 1", ""
347
+ ]
348
+ parts += [" %s" % child for child in children]
349
+ self.write(stub, "\n".join(parts) + "\n")
350
+ return stub
351
+
352
+ # ----------------------------------------------------------
353
+ # Cross-reference pass
354
+ # ----------------------------------------------------------
355
+
356
+ def scanUsages(self, root: Path) -> dict:
357
+ """Map each documented symbol's page to where it is used under a root.
358
+
359
+ An import binds a local name; the use sites are the occurrences of
360
+ that name. __init__.py is skipped as re-export plumbing, and a file
361
+ that does not parse is skipped. When a bound name is never used, the
362
+ import line is the fallback.
363
+
364
+ Parameters
365
+ ----------
366
+ root : Path
367
+ The directory tree to scan.
368
+
369
+ Returns
370
+ -------
371
+ dict
372
+ A mapping of page stub to '{using file (repo-relative posix):
373
+ [use lines]}'.
374
+ """
375
+ usage = {}
376
+ for pyfile in sorted(root.rglob("*.py")):
377
+ if pyfile.name == "__init__.py":
378
+ continue
379
+ try:
380
+ tree = ast.parse(pyfile.read_text(encoding="utf-8"))
381
+ except SyntaxError:
382
+ continue
383
+ rel = self.relPath(pyfile)
384
+ bindings = {}
385
+ importLine = {}
386
+ for node in ast.walk(tree):
387
+ if not isinstance(node, ast.ImportFrom):
388
+ continue
389
+ module = absModule(
390
+ self.__src_root__, pyfile, node.level, node.module
391
+ )
392
+ for alias in node.names:
393
+ page = self.__symbol_page__.get((module, alias.name))
394
+ if page is None or self.__file_page__.get(rel) == page:
395
+ continue
396
+ bindings[alias.asname or alias.name] = page
397
+ importLine.setdefault(page, node.lineno)
398
+ if not bindings:
399
+ continue
400
+ used = {}
401
+ for node in ast.walk(tree):
402
+ if isinstance(node, ast.Name) and node.id in bindings:
403
+ used.setdefault(bindings[node.id], set()).add(node.lineno)
404
+ for page in set(bindings.values()):
405
+ lines = used.get(page) or {importLine[page]}
406
+ usage.setdefault(page, {})[rel] = sorted(lines)
407
+ return usage
408
+
409
+ def appendSection(self, usage: dict, heading: str) -> None:
410
+ """Append a cross-reference section to each symbol page.
411
+
412
+ The section is a heading and a raw-HTML list, one row per using file,
413
+ every use line a link to that file's page at '#line-N'. A page that
414
+ was never written is skipped.
415
+
416
+ Parameters
417
+ ----------
418
+ usage : dict
419
+ The usage mapping from 'scanUsages'.
420
+ heading : str
421
+ The section heading, for example 'Used in'.
422
+ """
423
+ for page, files in usage.items():
424
+ path = self.__out__ / ("%s.rst" % page)
425
+ if not path.exists():
426
+ continue
427
+ items = []
428
+ for rel in sorted(files):
429
+ linenos = files[rel]
430
+ target = self.__file_page__.get(rel)
431
+ if target:
432
+ links = ", ".join(
433
+ '<a href="%s.html#line-%d">%d</a>' % (target, n, n)
434
+ for n in linenos
435
+ )
436
+ items.append(" <li>%s: %s</li>" % (rel, links))
437
+ else:
438
+ nums = ", ".join(str(n) for n in linenos)
439
+ items.append(" <li>%s: %s</li>" % (rel, nums))
440
+ block = [
441
+ "", heading, "-" * len(heading), "", ".. raw:: html", "",
442
+ " <ul>"
443
+ ] + items + [" </ul>"]
444
+ with path.open("a", encoding="utf-8") as handle:
445
+ handle.write("\n".join(block) + "\n")
446
+
447
+ # ----------------------------------------------------------
448
+ # Go-to-definition links
449
+ # ----------------------------------------------------------
450
+
451
+ def definitionLinks(self, pyfile: Path) -> dict:
452
+ """Map the source offset of every linkable name to its definition.
453
+
454
+ Four kinds are resolved, in order of precedence: a name imported from
455
+ a documented project symbol, to that symbol's page at '#line-N'; a
456
+ name imported from the standard library, to its docs.python.org page;
457
+ a name defined at module scope in this file, back to its definition on
458
+ this page; and a builtin, to its docs.python.org page. A name shadowed
459
+ by a local binding is left unlinked, so a link never points at the
460
+ wrong definition.
461
+
462
+ Parameters
463
+ ----------
464
+ pyfile : Path
465
+ The source file to resolve links for.
466
+
467
+ Returns
468
+ -------
469
+ dict
470
+ A mapping of absolute source offset to link target. Empty when the
471
+ file does not parse.
472
+ """
473
+ source = pyfile.read_text(encoding="utf-8")
474
+ try:
475
+ tree = ast.parse(source)
476
+ except SyntaxError:
477
+ return {}
478
+ starts = lineStarts(source)
479
+ selfPage = self.__file_page__.get(self.relPath(pyfile))
480
+ linkMap = {}
481
+
482
+ # Imported names: project symbols and standard-library members.
483
+ bindings = {}
484
+ aliasSites = []
485
+ for node in ast.walk(tree):
486
+ if isinstance(node, ast.ImportFrom):
487
+ module = absModule(
488
+ self.__src_root__, pyfile, node.level, node.module
489
+ )
490
+ for alias in node.names:
491
+ href = None
492
+ target = self.__symbol_def__.get((module, alias.name))
493
+ if target is not None and target[0] != selfPage:
494
+ href = "%s.html#line-%d" % target
495
+ elif (node.level == 0 and alias.name != "*"
496
+ and isStdlib(node.module)):
497
+ # docs.python.org strips leading underscores from the module
498
+ # in the anchor id, so '__future__' becomes 'future__'.
499
+ href = "%slibrary/%s.html#%s.%s" % (
500
+ PYDOCS, node.module, node.module.lstrip("_"), alias.name
501
+ )
502
+ if href is None:
503
+ continue
504
+ local = alias.asname or alias.name
505
+ bindings[local] = href
506
+ addAliasSite(aliasSites, alias, starts, local)
507
+ elif isinstance(node, ast.Import):
508
+ for alias in node.names:
509
+ if not isStdlib(alias.name):
510
+ continue
511
+ bound = alias.asname or alias.name.split(".")[0]
512
+ module = alias.name if alias.asname else alias.name.split(".")[0]
513
+ bindings[bound] = "%slibrary/%s.html" % (PYDOCS, module)
514
+ addAliasSite(aliasSites, alias, starts, bound)
515
+
516
+ if bindings:
517
+ shadowed = set()
518
+ for node in ast.walk(tree):
519
+ if isinstance(node, ast.Name) and isinstance(
520
+ node.ctx, (ast.Store, ast.Del)
521
+ ):
522
+ shadowed.add(node.id)
523
+ elif isinstance(node, ast.arg):
524
+ shadowed.add(node.arg)
525
+ for offset, name in aliasSites:
526
+ if name not in shadowed:
527
+ linkMap.setdefault(offset, bindings[name])
528
+ for node in ast.walk(tree):
529
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
530
+ if node.id in bindings and node.id not in shadowed:
531
+ offset = starts[node.lineno - 1] + node.col_offset
532
+ linkMap.setdefault(offset, bindings[node.id])
533
+
534
+ # Names defined at module scope: link each later use back to its
535
+ # definition on this same page.
536
+ moduleDefs = moduleDefinitions(tree)
537
+ if selfPage is not None and moduleDefs:
538
+ nested = nestedBound(tree)
539
+ for node in ast.walk(tree):
540
+ if not isinstance(node, ast.Name):
541
+ continue
542
+ if not isinstance(node.ctx, ast.Load):
543
+ continue
544
+ name = node.id
545
+ if name not in moduleDefs or name in nested:
546
+ continue
547
+ defLine = moduleDefs[name]
548
+ if node.lineno == defLine:
549
+ continue
550
+ offset = starts[node.lineno - 1] + node.col_offset
551
+ linkMap.setdefault(offset, "%s.html#line-%d" % (selfPage, defLine))
552
+
553
+ # Builtins: link every unshadowed use to docs.python.org.
554
+ bound = boundAnywhere(tree)
555
+ for node in ast.walk(tree):
556
+ if not isinstance(node, ast.Name) or not isinstance(
557
+ node.ctx, ast.Load
558
+ ):
559
+ continue
560
+ if node.id in BUILTIN_LINKS and node.id not in bound:
561
+ offset = starts[node.lineno - 1] + node.col_offset
562
+ linkMap.setdefault(offset, BUILTIN_LINKS[node.id])
563
+ return linkMap
564
+
565
+ def renderPending(self) -> None:
566
+ """Write the deferred HTML fragment for every recorded page.
567
+
568
+ By now the symbol-definition registry is complete, so a
569
+ go-to-definition link can resolve to a symbol defined anywhere in the
570
+ run.
571
+ """
572
+ for pyfile, stub in self.__pending_frags__:
573
+ source = pyfile.read_text(encoding="utf-8")
574
+ fragment = highlightWithLinks(source, self.definitionLinks(pyfile))
575
+ path = self.__out__ / ("%s.frag.html" % stub)
576
+ path.write_text(fragment, encoding="utf-8")
577
+
578
+ # ----------------------------------------------------------
579
+ # Entry point
580
+ # ----------------------------------------------------------
581
+
582
+ def run(self) -> None:
583
+ """Regenerate the output tree: the package, the tests mirror, the
584
+ packages index and the cross-references.
585
+
586
+ The package is documented as a single tree rooted at its __init__, so
587
+ a flat package is rendered just as readily as a nested one. The
588
+ registries are cleared first, so an instance may be run more than
589
+ once.
590
+ """
591
+ self.__symbol_page__.clear()
592
+ self.__symbol_def__.clear()
593
+ self.__file_page__.clear()
594
+ self.__pending_frags__.clear()
595
+ out = self.__out__
596
+ if out.exists():
597
+ shutil.rmtree(out)
598
+ out.mkdir(parents=True)
599
+
600
+ entries = [self.emitPackage(self.__pkg__)]
601
+
602
+ testEntry = None
603
+ if self.__tests_root__.is_dir():
604
+ testEntry = self.emitTestDir(self.__tests_root__)
605
+
606
+ lines = [".. toctree::", " :maxdepth: 1", ""]
607
+ lines += [" _source/%s" % entry for entry in entries]
608
+ if testEntry:
609
+ lines.append(" _source/%s" % testEntry)
610
+ (out / "_packages.txt").write_text(
611
+ "\n".join(lines) + "\n", encoding="utf-8"
612
+ )
613
+
614
+ self.renderPending()
615
+
616
+ self.appendSection(self.scanUsages(self.__pkg__), "Used in")
617
+ if self.__tests_root__.is_dir():
618
+ self.appendSection(
619
+ self.scanUsages(self.__tests_root__), "Usage in testing"
620
+ )
@@ -0,0 +1,26 @@
1
+ """
2
+ The 'title' function builds an rST section title.
3
+ """
4
+ # Apache-2.0 license
5
+ # Copyright (c) 2026 Asger Jon Vistisen
6
+
7
+
8
+ def title(text: str) -> str:
9
+ """Return an rST title: the text underlined by '=' of equal length.
10
+
11
+ Parameters
12
+ ----------
13
+ text : str
14
+ The title text.
15
+
16
+ Returns
17
+ -------
18
+ str
19
+ The text followed by a newline and a rule of '=' the same width.
20
+
21
+ Examples
22
+ --------
23
+ >>> title('abc')
24
+ 'abc\\n==='
25
+ """
26
+ return "%s\n%s" % (text, "=" * len(text))
@@ -0,0 +1,58 @@
1
+ """
2
+ The 'tokenHtml' function renders one Pygments token to HTML, optionally
3
+ wrapped in a go-to-definition anchor.
4
+ """
5
+ # Apache-2.0 license
6
+ # Copyright (c) 2026 Asger Jon Vistisen
7
+
8
+ import html
9
+
10
+ # The exact character-to-entity table the Pygments HtmlFormatter uses for
11
+ # text content, so a token's escaped text is byte-identical to stock
12
+ # Pygments. It escapes the five markup-significant characters, rendering
13
+ # the apostrophe as the decimal '&#39;' rather than Python html.escape's
14
+ # hexadecimal '&#x27;'.
15
+ _ESCAPE = {
16
+ ord("&"): "&amp;",
17
+ ord("<"): "&lt;",
18
+ ord(">"): "&gt;",
19
+ ord('"'): "&quot;",
20
+ ord("'"): "&#39;",
21
+ }
22
+
23
+
24
+ def tokenHtml(css: str, value: str, href: str) -> str:
25
+ """Return the HTML for one token.
26
+
27
+ The escaped text is wrapped in its Pygments class span when 'css' is
28
+ given, and wrapped again in a go-to-definition anchor when 'href' is
29
+ given. An external (docs.python.org) target gets a second class and
30
+ opens in a new tab, so a jump to the Python docs does not navigate away
31
+ from the project docs.
32
+
33
+ Parameters
34
+ ----------
35
+ css : str
36
+ The Pygments CSS class, or the empty string for no span.
37
+ value : str
38
+ The token's literal text.
39
+ href : str
40
+ The link target, or the empty string for no anchor. A target
41
+ starting with 'http' is treated as external.
42
+
43
+ Returns
44
+ -------
45
+ str
46
+ The rendered HTML fragment for the token.
47
+ """
48
+ inner = value.translate(_ESCAPE)
49
+ if css:
50
+ inner = '<span class="%s">%s</span>' % (css, inner)
51
+ if not href:
52
+ return inner
53
+ if href.startswith("http"):
54
+ return ('<a class="srcref srcref-ext" target="_blank" rel="noopener" '
55
+ 'href="%s">%s</a>' % (html.escape(href, quote=True), inner))
56
+ return '<a class="srcref" href="%s">%s</a>' % (
57
+ html.escape(href, quote=True), inner
58
+ )