Repository navigation
Expand file tree
/
Copy pathcheck_markdown_links.py
More file actions
2532 lines (2310 loc) · 99.5 KB
/
Copy pathcheck_markdown_links.py
File metadata and controls
2532 lines (2310 loc) · 99.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Check the relative links and anchors of the tracked Markdown files.
Every relative link and image target of a tracked Markdown file outside the
submodules, whether an inline link, a reference definition, or the href of an
HTML link or src of an image, must name a tracked file or directory, and the
anchor of every link must match, in lowercase as GitHub looks it up, a heading
slug that GitHub generates, or the id or name of an HTML element that GitHub
keeps, in the target document; other files have only line anchors such as
#L10. An image's anchor, such as GitHub's #gh-dark-mode-only, selects nothing
to check, and an empty target links to its own document. As on GitHub, code
blocks, code spans, HTML blocks, and front matter hold no Markdown links, an
HTML comment hides what renders up to its end, the tags that GitHub filters
render as text, and block quotes and list items decide where the blocks inside
them end. A link into a submodule resolves against its pinned revision when the
submodule is checked out and is skipped otherwise.
Every doc/ path, with any anchor, that a tracked text file mentions must
resolve the same way from the repository root, except the test fixtures that
DOC_PATH_FIXTURES lists.
"""
from __future__ import annotations
import argparse
import bisect
import html.entities
import os
import posixpath
import re
import subprocess
import sys
import unicodedata
import urllib.parse
from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import NamedTuple
REPO_ROOT = Path(__file__).resolve().parents[1]
# Test fixtures that mention doc/ paths that do not exist, by the file that
# mentions them. An entry that its file no longer needs fails the check, and
# this file may mention every path that the entries list.
DOC_PATH_FIXTURES: Mapping[str, Collection[str]] = {
"scripts/test_markdown_links.py": frozenset({"doc/guide.md", "doc/missing.md"}),
"scripts/test_nvx_tools.py": frozenset({"doc/input.patch", "doc/notes.md"}),
}
_ASCII_PUNCTUATION = frozenset("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~")
_ESCAPED = re.compile(r"\\([!-/:-@\[-`{-~])")
# Character references, as cmark decodes them in Markdown, and as GitHub
# decodes them in HTML attributes, where a number needs no semicolon.
_ENTITY = re.compile(
r"&(?:#[0-9]{1,7}|#[xX][0-9A-Fa-f]{1,6}|[A-Za-z][A-Za-z0-9]{1,30});"
)
_HTML_ENTITY = re.compile(r"&(?:#[0-9]+;?|#[xX][0-9A-Fa-f]+;?|[A-Za-z][A-Za-z0-9]*;)")
_EXTERNAL = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:|//")
_SPACE = re.compile(r"[ \t]*(?:\n[ \t]*)?")
_LINE_BREAK = re.compile(r"\n[ \t]*")
_BLANK_LINE = re.compile(r"\n[ \t]*(?:\n|$)")
_BACKTICKS = re.compile(r"`+")
# Block syntax, which starts at a line's first character that is not a space
# or tab, fewer than four columns into its container.
_LIST_MARKER = re.compile(r"(?:[-+*]|([0-9]{1,9})[.)])(?=[ \t]|$)")
_FENCE_OPENER = re.compile(r"(`{3,}|~{3,})(.*)")
_FENCE_CLOSER = re.compile(r"(`{3,}|~{3,})[ \t]*")
_THEMATIC_BREAK = re.compile(r"(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,}")
_ATX_HEADING = re.compile(r"#{1,6}(?:[ \t]+(.*))?")
_CLOSING_SEQUENCE = re.compile(r"(?:^|[ \t]+)#+[ \t]*$")
_SETEXT_UNDERLINE = re.compile(r"(?:=+|-+)[ \t]*")
# A table needs a delimiter row whose cells match its header row's.
_TABLE_DELIMITER = re.compile(
r"[ \t]*\|?(?:[ \t]*:?-+:?[ \t]*\|)*[ \t]*:?-+:?[ \t]*\|?[ \t]*"
)
_FOOTNOTE = re.compile(r"\[\^([^\]\s]+)\]:")
_FOOTNOTE_LABEL = re.compile(r"\^([^\]\s]+)")
# Raw HTML, after CommonMark: tags are atomic inline, and their attribute
# values are literal.
_TAG_ATTRIBUTE = re.compile(
r"""\s+([A-Za-z_:][A-Za-z0-9_.:-]*)"""
r"""(?:\s*=\s*(?:([^\s"'=<>`]+)|'([^']*)'|"([^"]*)"))?"""
)
_OPEN_TAG = re.compile(
r"<[A-Za-z][A-Za-z0-9-]*(?:" + _TAG_ATTRIBUTE.pattern + r")*\s*/?>"
)
_CLOSE_TAG = re.compile(r"</[A-Za-z][A-Za-z0-9-]*\s*>")
# The start conditions of the HTML blocks of types 1 to 6, whose lines GitHub
# passes through without parsing Markdown, as cmark finds them, and the end
# conditions of types 1 to 5, which, unlike the others, no blank line ends.
_HTML_STARTS = (
re.compile(r"<(?:pre|script|style|textarea)(?:[ \t>]|$)", re.I),
re.compile("<!--"),
re.compile(r"<\?"),
re.compile("<![A-Z]"),
re.compile(r"<!\[CDATA\["),
re.compile(
r"</?(?:address|article|aside|base|basefont|blockquote|body|caption"
r"|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset"
r"|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr"
r"|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol"
r"|optgroup|option|p|param|search|section|summary|table|tbody|td"
r"|tfoot|th|thead|title|tr|track|ul)(?:[ \t>]|/>|$)",
re.I,
),
)
_HTML_ENDS = (
re.compile(r"</(?:pre|script|style|textarea)>", re.I),
re.compile("-->"),
re.compile(r"\?>"),
re.compile(">"),
re.compile(r"\]\]>"),
)
_HTML_TAG_LINE = re.compile(
"(?:" + _OPEN_TAG.pattern + "|" + _CLOSE_TAG.pattern + r")[ \t]*"
)
# What an HTML parser finds in raw HTML: a comment ends at "-->" or "--!>", or
# at once as "<!-->" or "<!--->", while other markup that starts with "<!",
# "<?", or "</" but is no tag, such as a CDATA section, ends at the first ">".
_MARKUP = re.compile(r"<(?:!--|[!?/A-Za-z])")
_COMMENT_END = re.compile("--!?>")
_ABRUPT_COMMENT_END = re.compile("-?>")
# What the marks of marked text, as parse_markdown makes it, render.
_UNMARK = str.maketrans({"\x00": "", "\x01": " ", "\x02": " ", "\x03": " "})
# GitHub escapes the "<" of these tags, which then render as text.
_FILTERED_TAG = re.compile(
r"</?(?:iframe|noembed|noframes|plaintext|script|style|textarea|title|xmp)"
r"(?=[ \t\n\v\f\r>]|/>)",
re.I,
)
# What GitHub's sanitizer keeps: the targets of links and images, and the
# elements whose id and name attributes are anchors.
_TARGET_ATTRIBUTES = frozenset((("a", "href"), ("img", "src")))
_ANCHOR_ELEMENTS = frozenset(
"a b blockquote br code dd del details div dl dt em h1 h2 h3 h4 h5 h6 hr i"
" img ins kbd li mark ol p picture pre q rp rt ruby s samp section source"
" span strike strong sub summary sup table tbody td tfoot th thead tr tt"
" ul var".split()
)
# The HTML headings, whose text GitHub slugs too.
_HEADINGS = frozenset(("h1", "h2", "h3", "h4", "h5", "h6"))
# GitHub slugs the text of each heading in the tree that libxml2's HTML parser
# builds from its HTML, as an HTML5 parser rebuilds it. libxml2 closes the
# current element while a start tag that this table lists for it follows, as
# its htmlStartClose table does for each element that can stay open here.
_CLOSED_BY: Mapping[str, frozenset[str]] = {
old: frozenset(new.split())
for old, new in {
"a": "a fieldset table td th",
"address": "dd dl dt form li ul",
"b": "center p td th",
"big": "p",
"caption": "col colgroup tbody tfoot thead tr",
"colgroup": "colgroup tbody tfoot thead tr",
"dd": "dt",
"dir": "dd dl dt form ul",
"dl": "form li",
"dt": "dd dl",
"font": "center td th",
"form": "form",
**dict.fromkeys(_HEADINGS, "fieldset form li p table"),
"i": "center p td th",
"legend": "fieldset",
"li": "li",
"listing": "dd dl dt fieldset form li table ul",
"menu": "dd dl dt form ul",
"ol": "form",
"option": "optgroup option",
"p": "address blockquote body caption center col colgroup dd dir div dl dt"
" fieldset form frameset h1 h2 h3 h4 h5 h6 head hr li listing menu ol p pre"
" table tbody td tfoot th tr ul",
"pre": "dd dl dt fieldset form li table ul",
"s": "p",
"small": "p",
"span": "td th",
"strike": "p",
"tbody": "tbody tfoot",
"td": "tbody td tfoot th tr",
"tfoot": "tbody",
"th": "tbody td tfoot th tr",
"thead": "tbody tfoot",
"tr": "tbody tfoot tr",
"tt": "p",
"u": "p td th",
"ul": "address form menu pre",
}.items()
}
# An end tag closes the elements after its own only if none of them has a
# higher priority than its own, of 100 unless listed here; libxml2 ignores it
# otherwise, as it does an end tag of no open element.
_END_PRIORITY: Mapping[str, int] = {
"div": 150,
"td": 160,
"th": 160,
"tr": 170,
"thead": 180,
"tbody": 180,
"tfoot": 180,
"table": 190,
}
# The elements that libxml2 never leaves open, and those whose start tags it
# discards after they close others.
_VOID_ELEMENTS = frozenset(
"area base basefont br col frame hr img input isindex link meta param".split()
)
_DISCARDED_ELEMENTS = frozenset(("body", "head", "html"))
# The elements that GitHub renders for block quotes, the items of bullet and
# ordered lists, and footnote definitions, around what they hold.
_CONTAINER_ELEMENTS: Mapping[str, tuple[str, ...]] = {
"quote": ("blockquote",),
"item": ("ul", "li"),
"ordered": ("ol", "li"),
"footnote": ("section", "ol", "li"),
}
_TAG_START = re.compile("</?[A-Za-z]")
# The prefix that GitHub gives each id and name that lacks it, and that its
# script adds to each fragment that lacks it before it looks one up.
_PREFIX = "user-content-"
_LABEL = re.compile(r"\[(?!\^)((?:[^\[\]\\]|\\.){1,999})\]:")
_LINE_END = re.compile(r"[ \t]*(?=\n|\Z)")
_README_NAMES = frozenset(("readme.md", "readme.markdown"))
_AUTOLINK = re.compile(r"<[A-Za-z][A-Za-z0-9+.-]{1,31}:[^<>\s]*>")
_EMAIL_AUTOLINK = re.compile(
r"<[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*>"
)
# Comments, processing instructions, declarations, and CDATA sections.
_RAW_HTML = re.compile(
r"<!-{2,3}>|<!--.*?-->|<\?.*?\?>|<![A-Z]+[ \t\n\v\f\r][^>]*>"
r"|<!\[CDATA\[.*?\]\]>",
re.S,
)
# GitHub keeps letters, marks, digits, connector punctuation such as "_",
# hyphens, and spaces, which become hyphens.
_SLUG_CATEGORIES = frozenset(
("Ll", "Lm", "Lo", "Lt", "Lu", "Mc", "Me", "Mn", "Nd", "Nl", "Pc")
)
_LINE_ANCHOR = re.compile(r"L([0-9]+)(?:C[0-9]+)?(?:-L([0-9]+)(?:C[0-9]+)?)?")
_DOC_PATH = re.compile(r"(?<![\w./\\-])doc/[\w./-]*(?:#[\w.-]*)?")
# Whitespace or one of these ends a doc/ path. Any other character, such as the
# "*" of a pattern, the "{" of a template, or the "+" of a file name that the
# expression does not match, leaves the path ambiguous, so it is not checked.
_DOC_PATH_ENDS = frozenset("\"'`,;:!?)]}>|\\")
class CheckError(RuntimeError):
"""Raised when the tracked files of the work tree cannot be listed."""
@dataclass(frozen=True, order=True)
class Problem:
"""A link target or a doc/ path that does not resolve."""
path: str
line: int
message: str
def __str__(self) -> str:
location = f"{self.path}:{self.line}" if self.line else self.path
return f"{location}: {self.message}"
def annotation(self) -> str:
"""Formats the problem as a GitHub Actions error annotation."""
properties = f"file={_escape_property(self.path)}"
if self.line:
properties += f",line={self.line}"
return f"::error {properties}::{_escape_data(str(self))}"
@dataclass(frozen=True)
class Link:
"""A link or image target, as a browser receives it, and its span in the
text."""
line: int
start: int
end: int
target: str
image: bool = False
@dataclass(frozen=True)
class MarkdownDocument:
"""The link targets and anchors of a Markdown document, each anchor being
the id of an element that GitHub renders, without its user-content-
prefix."""
links: tuple[Link, ...]
anchors: frozenset[str]
def _escape_data(value: str) -> str:
return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
def _escape_property(value: str) -> str:
return _escape_data(value).replace(":", "%3A").replace(",", "%2C")
def normalize_newlines(text: str) -> str:
return text.replace("\r\n", "\n").replace("\r", "\n")
def github_slug(text: str) -> str:
"""Returns the anchor that GitHub derives from a heading's text."""
return "".join(
"-" if char == " " else char
for char in text.lower()
if char in " -" or unicodedata.category(char) in _SLUG_CATEGORIES
)
def parse_markdown(text: str) -> MarkdownDocument:
"""Finds the link targets and anchors of Markdown as GitHub renders it.
Offsets and line numbers refer to the text after normalize_newlines.
"""
text = normalize_newlines(text)
lines = text.split("\n")
starts = [0, *(match.end() for match in re.finditer("\n", text))]
layout = _layout(lines)
# Blank what renders no Markdown and, as CommonMark strips them before
# parsing inline content, the block quote and list item markers. The
# text of HTML headings keeps the indentation after those markers, so
# marked holds each marker, and each control character that renders
# nothing in a slug, as "\x00", and each tab that a container consumed in
# part as the control character whose code counts the spaces it renders.
chars = list(text)
marks = ["\x00" if char < "\x04" else char for char in text]
for number, line in enumerate(lines):
end = len(line) if layout.hidden[number] else layout.content[number]
_blank(chars, starts[number], starts[number] + end)
prefix = layout.prefixes[number]
marks[starts[number] : starts[number] + prefix] = ["\x00"] * prefix
if layout.pads[number] and not layout.hidden[number]:
marks[starts[number] + prefix] = chr(layout.pads[number])
blanked = "".join(chars)
marked = "".join(marks)
definitions, firsts = _definitions(blanked, lines, layout, starts)
labels = {definition[0] for definition in definitions}
footnotes: dict[str, str] = {}
for label, _, _ in layout.footnotes:
footnotes.setdefault(label.casefold(), label)
# The raw HTML, by the index of each HTML block where it ends, the inline
# links, each as the offset where it renders and the span of its
# destination, the footnote citations, and the block and the span of the
# inline content that holds each item of inline raw HTML, by where the
# item ends.
regions = {
starts[end - 1] + len(lines[end - 1]): index
for index, (kind, _, end) in enumerate(layout.blocks)
if kind == "html"
}
raw = [(starts[layout.blocks[index][1]], end) for end, index in regions.items()]
inline: list[tuple[int, int, int, bool]] = []
citations: list[tuple[int, str]] = []
spans: dict[int, tuple[int, int, int]] = {}
# Each use of a reference, as its offset, whether an image uses it, and
# its label, since a target that only rendered images use holds no anchor.
uses: list[tuple[int, bool, str]] = []
for index, start, end in _inline_ranges(lines, layout, starts, firsts):
content = blanked[start:end]
items = _inline(content, labels, footnotes)
for item in items:
if item.kind in ("tag", "html"):
raw.append((start + item.start, start + item.end))
spans[start + item.end] = (index, start, end)
elif item.kind == "footnote":
label = blanked[start + item.start + 2 : start + item.close]
citations.append((start + item.start, label.casefold()))
elif item.destination is not None:
first, last = item.destination
inline.append(
(
start + item.start,
start + first,
start + last,
item.kind == "image",
)
)
elif item.label is not None:
uses.append((start + item.start, item.kind == "image", item.label))
citations.sort()
# GitHub renders the cited footnotes after the rest of the document, in
# the order of their first citations, and the others not at all.
ranks: dict[str, int] = {}
for _, key in citations:
ranks.setdefault(key, len(ranks) + 1)
rank = [0] * len(lines)
for label, first, end in layout.footnotes:
rank[first:end] = [ranks.get(label.casefold(), -1)] * (end - first)
def place(offset: int) -> tuple[int, int]:
return rank[bisect.bisect_right(starts, offset) - 1], offset
tags, comments = _raw_html(
blanked,
sorted(
(span for span in raw if place(span[0])[0] >= 0),
key=lambda span: place(span[0]),
),
)
hidden = [
(place(start), (len(ranks) + 1, 0) if end is None else place(end))
for start, end in comments
]
openings = [start for start, _ in hidden]
def shown(offset: int) -> bool:
"""Whether GitHub renders what is at offset, outside any comment."""
at = place(offset)
index = bisect.bisect_right(openings, at) - 1
return at[0] >= 0 and (index < 0 or hidden[index][1] <= at)
attributes = [
(tag.name, key, start, end)
for tag in tags
for key, start, end in tag.attributes
]
references: dict[bool, set[str]] = {True: set(), False: set()}
for offset, image, label in uses:
if shown(offset):
references[image].add(label)
# A definition counts where it renders, as do the definitions that the
# rendered references use, wherever they are, such as in hidden footnotes.
# The text of each target is blanked, without the markers of the
# containers that its lines continue.
targets = [
(
start,
end,
_destination(blanked[start:end]),
label in references[True] and label not in references[False],
)
for label, start, end, _ in definitions
if shown(start) or label in references[True] | references[False]
]
targets.extend(
(start, end, _destination(blanked[start:end]), image)
for offset, start, end, image in inline
if shown(offset)
)
targets.extend(
(start, end, _url(blanked[start:end]), element == "img")
for element, name, start, end in attributes
if (element, name) in _TARGET_ATTRIBUTES
)
links = tuple(
Link(bisect.bisect_right(starts, start), start, end, target, image)
for start, end, target, image in sorted(targets)
)
anchors = {
_attribute(blanked[start:end]).removeprefix(_PREFIX)
for element, name, start, end in attributes
if element in _ANCHOR_ELEMENTS and name in ("id", "name")
}
counts: dict[str, int] = {}
for offset, key in citations:
counts[key] = counts.get(key, 0) + 1
if shown(offset):
suffix = "" if counts[key] == 1 else f"-{counts[key]}"
anchors.add(f"fnref-{footnotes[key]}{suffix}")
anchors.update(
f"fn-{footnotes[label.casefold()]}"
for label, first, _ in layout.footnotes
if shown(starts[first])
)
# GitHub numbers the repeated slugs of Markdown and HTML headings alike,
# in the order that it renders them.
texts = _Texts(
lines,
starts,
layout,
blanked,
marked,
firsts,
labels,
ranks,
regions,
spans,
place,
shown,
_tight(layout, firsts),
)
headings: list[tuple[tuple[int, int], str]] = []
for index, (_, first, _) in enumerate(layout.blocks):
offset = starts[first] + layout.content[first]
if shown(offset) and (heading := texts.markdown(index)) is not None:
headings.append((place(offset), heading))
for tag in tags:
if tag.name in _HEADINGS and (heading := texts.html(tag)) is not None:
headings.append((place(tag.start), heading))
anchors.update(_slugs(heading for _, heading in sorted(headings)))
anchors.discard("")
return MarkdownDocument(links, frozenset(anchors))
def _blank(chars: list[str], start: int, end: int) -> None:
"""Replaces the characters in a span with spaces, keeping line endings."""
chars[start:end] = (" " if char != "\n" else char for char in chars[start:end])
def _definitions(
blanked: str, lines: Sequence[str], layout: _Layout, starts: Sequence[int]
) -> tuple[list[tuple[str, int, int, int]], list[int]]:
"""Parses the link reference definitions at the start of each paragraph,
within that paragraph.
Returns the definitions, and the first line of each block after them,
where its inline content starts.
"""
definitions: list[tuple[str, int, int, int]] = []
firsts: list[int] = []
for kind, first, end in layout.blocks:
number = first
if kind in ("paragraph", "setext"):
base = starts[first]
paragraph = blanked[base : starts[end - 1] + len(lines[end - 1])]
while number < end:
offset = starts[number] + layout.content[number] - base
definition = _definition(paragraph, offset)
if definition is None:
break
label, start, stop, last = definition
definitions.append((label, base + start, base + stop, base + last))
number = bisect.bisect_right(starts, base + last)
firsts.append(number)
return definitions, firsts
def _definition(text: str, index: int) -> tuple[str, int, int, int] | None:
"""Parses a link reference definition at index of text whose container
markers are blank.
Returns its normalized label, the span of its destination, and the end of
its last line, or None when the text there holds no definition. Like
GitHub, it accepts any destination without spaces, even one whose
parentheses do not balance.
"""
label = _LABEL.match(text, index)
if label is None or not label[1].strip():
return None
start = _skip_space(text, label.end())
if text.startswith("<", start):
end = _pointy(text, start)
if end is None:
return None
destination = (start + 1, end)
end += 1
else:
end = start
while end < len(text) and text[end] > " ":
end += 1
if end == start:
return None
destination = (start, end)
name = _label(label[1])
title = _skip_space(text, end)
if title > end and text[title : title + 1] in ('"', "'", "("):
# A title in parentheses holds no other unescaped parenthesis.
stops = "()" if text[title] == "(" else text[title]
close = title + 1
while close < len(text) and text[close] not in stops:
if _BLANK_LINE.match(text, close):
break
close += 2 if text[close] == "\\" else 1
after = _LINE_END.match(text, close + 1)
if text.startswith(stops[-1], close) and after is not None:
return name, destination[0], destination[1], after.end()
# A title that does not end its line leaves the definition before it.
after = _LINE_END.match(text, end)
if after is None:
return None
return name, destination[0], destination[1], after.end()
def _raw_html(
text: str, regions: Iterable[tuple[int, int]]
) -> tuple[list[_Tag], list[tuple[int, int | None]]]:
"""Tokenizes raw HTML as an HTML parser does, given its spans of text in
the order that GitHub renders them.
Returns the open tags, and the span of each comment, where a comment that
one span leaves open hides everything that renders after it up to the end
of the comment in a later span, or with no end, None.
"""
tags: list[_Tag] = []
comments: list[tuple[int, int | None]] = []
opened: int | None = None
for start, end in regions:
index = start
while index < end:
if opened is not None:
close = _COMMENT_END.search(text, index, end)
if close is None:
break
comments.append((opened, close.end()))
opened, index = None, close.end()
continue
markup = _MARKUP.search(text, index, end)
if markup is None:
break
index = markup.start()
if _FILTERED_TAG.match(text, index, end):
index += 1
elif markup[0] == "<!--":
abrupt = _ABRUPT_COMMENT_END.match(text, markup.end(), end)
if abrupt is None:
opened, index = index, markup.end()
else:
comments.append((index, abrupt.end()))
index = abrupt.end()
elif _TAG_START.match(text, index, end):
tag = _html_tag(text, index, end)
if tag is None:
index = markup.end()
continue
if text[index + 1] != "/":
tags.append(tag)
index = tag.end
elif markup[0][1] in "!?/":
close = text.find(">", markup.end(), end)
comments.append((index, end if close < 0 else close + 1))
index = end if close < 0 else close + 1
else:
index = markup.end()
if opened is not None:
comments.append((opened, None))
return tags, comments
@dataclass(frozen=True)
class _Tag:
"""A start or end tag of raw HTML: the offsets where it starts and ends,
the end of the raw HTML that holds it, its lowercase name, the lowercase
name and value span of each of its attributes, and whether it closes
itself, with "/>"."""
start: int
end: int
region: int
name: str
attributes: tuple[tuple[str, int, int], ...]
closed: bool = False
def _html_tag(text: str, start: int, end: int) -> _Tag | None:
"""Tokenizes the tag at start as an HTML parser does, or returns None
when it does not end before end. As in HTML, the first attribute of each
name, in any case, hides the others, and one without a value has an
empty one."""
index = start + (2 if text[start + 1] == "/" else 1)
first = index
while index < end and text[index] not in "\t\n\f\r />":
index += 1
name = text[first:index].lower()
attributes: list[tuple[str, int, int]] = []
seen: set[str] = set()
last = index # where the name or the last attribute ends
while index < end:
if text[index] in "\t\n\f\r /":
index += 1
continue
if text[index] == ">":
closed = text[index - 1] == "/" and index > last
return _Tag(start, index + 1, end, name, tuple(attributes), closed)
first = index
index += 1
while index < end and text[index] not in "\t\n\f\r />=":
index += 1
key = text[first:index].lower()
while index < end and text[index] in "\t\n\f\r ":
index += 1
value = (index, index)
if index < end and text[index] == "=":
index += 1
while index < end and text[index] in "\t\n\f\r ":
index += 1
if index < end and text[index] in "\"'":
close = text.find(text[index], index + 1, end)
if close < 0:
return None
value, index = (index + 1, close), close + 1
else:
first = index
while index < end and text[index] not in "\t\n\f\r >":
index += 1
value = (first, index)
if key not in seen:
seen.add(key)
attributes.append((key, *value))
last = index
return None
class _Event(NamedTuple):
"""A start or end tag that GitHub's HTML holds: the name of its element,
whether it ends the element or, as "/>" does, starts and ends it, and the
offset of raw HTML in the text that holds it, or -1."""
name: str
end: bool = False
closed: bool = False
offset: int = -1
def _event(text: str, tag: _Tag) -> _Event:
return _Event(tag.name, text[tag.start + 1] == "/", tag.closed, tag.start)
class _Elements:
"""The elements that GitHub's HTML holds open, as libxml2's HTML parser
nests them and an HTML5 parser rebuilds them, where a heading closes the
heading that it starts in. It collects the text of one element, which a
start tag opens before mark is called."""
def __init__(self, ancestors: Iterable[str]) -> None:
self.stack = list(ancestors)
self.depth: int | None = None
self.opened = False
self.closed = False
self.pieces: list[str] = []
@property
def text(self) -> str:
return "".join(self.pieces)
def mark(self) -> None:
"""Collects the text of the element that the last start tag opened."""
self.depth = len(self.stack) - 1
self.closed = not self.opened
def feed(self, token: str | _Event) -> None:
if isinstance(token, str):
if self.depth is not None and not self.closed:
self.pieces.append(token)
elif token.end:
self._end(token.name)
else:
self._start(token.name, token.closed)
def _start(self, name: str, closed: bool) -> None:
while self.stack and name in _CLOSED_BY.get(self.stack[-1], ()):
self._pop(len(self.stack) - 1)
if name in _HEADINGS and self.stack and self.stack[-1] in _HEADINGS:
self._pop(len(self.stack) - 1)
self.opened = not (
closed or name in _VOID_ELEMENTS or name in _DISCARDED_ELEMENTS
)
if self.opened:
self.stack.append(name)
def _end(self, name: str) -> None:
priority = _END_PRIORITY.get(name, 100)
for index in range(len(self.stack) - 1, -1, -1):
if self.stack[index] == name:
self._pop(index)
return
if _END_PRIORITY.get(self.stack[index], 100) > priority:
return
def _pop(self, index: int) -> None:
"""Closes the elements from index of the stack."""
del self.stack[index:]
if self.depth is not None and index <= self.depth:
self.closed = True
def _raw_tokens(
text: str, index: int, end: int, unmark: bool = True
) -> Iterator[tuple[int, str | _Event]]:
"""Yields the text, with its character references decoded, and the tags
of raw HTML from index to end as an HTML parser tokenizes it, each with
its offset. The text is marked as parse_markdown marks it, unless unmark
is false. A comment renders nothing, and one that does not end there
hides the rest."""
table = _UNMARK if unmark else {}
while index < end:
markup = _MARKUP.search(text, index, end)
stop = end if markup is None else markup.start()
if stop > index:
yield index, _attribute(text[index:stop].translate(table))
if markup is None:
return
index = stop
if _FILTERED_TAG.match(text, index, end):
yield index, "<"
index += 1
elif markup[0] == "<!--":
close = _ABRUPT_COMMENT_END.match(
text, markup.end(), end
) or _COMMENT_END.search(text, markup.end(), end)
if close is None:
return
index = close.end()
elif _TAG_START.match(text, index, end):
tag = _html_tag(text, index, end)
if tag is None:
return
yield index, _event(text, tag)
index = tag.end
elif markup[0][1] in "!?/":
close = text.find(">", markup.end(), end)
if close < 0:
return
index = close + 1
else:
yield index, markup[0]
index = markup.end()
def _character(reference: str) -> str | None:
"""Decodes a character reference such as "&", "&", or "&",
or returns None for an unknown name."""
name = reference[1:].rstrip(";")
if not name.startswith("#"):
return html.entities.html5.get(f"{name};")
code = int(name[2:], 16) if name[1] in "xX" else int(name[1:])
if 0 < code < 0x110000 and not 0xD800 <= code < 0xE000:
return chr(code)
return "\ufffd"
def _decode(match: re.Match[str]) -> str:
return _character(match[0]) or match[0]
def _destination(text: str) -> str:
"""Decodes a Markdown link destination as cmark does: it trims the
destination's whitespace, and then decodes its character references and
then its backslash escapes."""
return _ESCAPED.sub(r"\1", _ENTITY.sub(_decode, text.strip(" \t\n\v\f\r")))
def _attribute(text: str) -> str:
"""Decodes an HTML attribute value as GitHub does, which keeps its
backslashes, and its named character references without semicolons,
literal."""
return _HTML_ENTITY.sub(_decode, text)
def _url(text: str) -> str:
"""Decodes the URL of an HTML attribute as GitHub does, which drops its
leading whitespace and control characters but keeps its trailing ones."""
return _attribute(text).lstrip("".join(map(chr, range(0x21))))
@dataclass(eq=False)
class _Node:
"""A block of the tree that cmark builds, as far as the tightness of lists
needs it: its kind, its parent and children, and whether cmark marks its
last line as blank. A list has the marker that its items share, an item
the line where it starts, and a paragraph the index of its block. A list
that a new block of its parent ends while a paragraph inside it is still
open, which cmark finalizes only after the list, holds that paragraph."""
kind: str
parent: _Node | None = None
children: list[_Node] = field(default_factory=list["_Node"])
blank: bool = False
key: str = ""
line: int = -1
index: int = -1
pending: _Node | None = None
def add(self, child: _Node) -> _Node:
child.parent = self
self.children.append(child)
return child
@dataclass(frozen=True)
class _Layout:
"""The blocks of the lines of a Markdown document.
hidden marks the lines that render no Markdown: front matter and code.
prefixes holds the offset where each line's container markers end, and
content the offset where its content starts, after its indentation too,
which a line of code keeps beyond that of its block. pads holds the
columns of the tab at the prefix of each line, or at the content of a
line of code, that renders as spaces since a container or the code's
indentation consumed only part of it. scopes identifies the containers
that hold each line's content, whose kinds, "quote", "item", "ordered"
for the item of an ordered list, or "footnote", kinds holds, and whose
nodes in cmark's tree nodes holds.
blocks lists the paragraphs, tables, headings, HTML blocks, code blocks,
and thematic breaks in order, each as a kind, its first line, and its
end line, which is the underline of a setext heading or the closing
fence of code; a paragraph of the kind "text", before a table's header,
holds no definitions. footnotes lists each footnote definition as its
label, its first line, and its end line.
"""
hidden: list[bool]
prefixes: list[int]
content: list[int]
pads: list[int]
scopes: list[tuple[int, ...]]
kinds: list[str]
nodes: list[_Node]
blocks: list[tuple[str, int, int]]
footnotes: list[tuple[str, int, int]]
def _layout(lines: Sequence[str]) -> _Layout:
"""Finds the blocks of Markdown lines as cmark nests them.
Block quotes, list items, and GitHub's footnote definitions contain the
other blocks. A line continues each container whose marker or
indentation it repeats, or a paragraph as a lazy continuation, and every
other container ends with the code block, paragraph, table, or HTML
block that it holds. Indentation counts columns, which a tab advances to
the next multiple of four.
"""
hidden = [False] * len(lines)
prefixes = [0] * len(lines)
content = [0] * len(lines)
pads = [0] * len(lines)
scopes: list[tuple[int, ...]] = [()] * len(lines)
kinds: list[str] = []
blocks: list[tuple[str, int, int]] = []
footnotes: list[tuple[str, int, int]] = []
# 0 for a block quote, else the content width of a list item or footnote,
# whose index in footnotes owners holds, and whether each container is a
# list item that holds nothing yet, which a blank line ends. Each
# container's index in kinds is in serials.
containers: list[int] = []
owners: list[int | None] = []
empty: list[bool] = []
serials: list[int] = []
# cmark's tree of blocks: the node of each container, by its serial, the
# node of the open leaf, or of the last one, and the paragraph that the
# line interrupts until the line opens its first block.
document = _Node("document")
nodes: list[_Node] = []
block = document
interrupted: _Node | None = None
leaf = "" # the open paragraph, table, fence, code, or html block
fence = ""
indent = 0 # the columns of a fence's indentation, which its code drops
ending: re.Pattern[str] | None = None # how the html block ends, if not blank
tried = False # whether the paragraph failed to start a table
start = 0
def close(end: int) -> None:
nonlocal leaf
if leaf == "paragraph":
block.index = len(blocks)
if leaf in ("paragraph", "table", "html"):
blocks.append((leaf, start, end))
elif leaf in ("fence", "code") and start < end:
blocks.append(("code", start, end))
leaf = ""
def holder() -> _Node:
return nodes[serials[-1]] if serials else document
def adopt(child: _Node) -> _Node:
# A block other than a paragraph ends the list before it at once, and
# so before the paragraph that its line interrupts.
nonlocal interrupted
parent = holder()
if (
parent.children
and parent.children[-1].kind == "list"
and child.kind != "paragraph"
):
parent.children[-1].pending = interrupted
interrupted = None
return parent.add(child)
def mark(node: _Node, blank: bool, last: bool) -> None:
# As cmark's last_line_blank: a line marks the deepest block that it
# continues as last, a blank line marks that block's last child too,
# and neither marks the blocks around them.
if blank and node.children:
node.children[-1].blank = True
node.blank = last
while node.parent is not None:
node = node.parent
node.blank = False
def push(width: int, owner: int | None, vacant: bool, line: int, key: str) -> None:
if not width or owner is not None:
nodes.append(adopt(_Node("quote" if not width else "footnote")))
else:
# An item continues the list before it that has the same marker.
items = holder().children[-1] if holder().children else None
if items is None or items.kind != "list" or items.key != key:
items = adopt(_Node("list", key=key))
nodes.append(items.add(_Node("item", line=line)))
empty[:] = [False] * len(empty)
containers.append(width)
owners.append(owner)
empty.append(vacant)
serials.append(len(kinds))
if not width:
kinds.append("quote")
elif owner is not None:
kinds.append("footnote")