26
26
import yaml
27
27
28
28
from babel .dates import format_date , format_datetime
29
+ from copy import copy
29
30
from datetime import datetime , timezone
30
31
from jinja2 import pass_context
31
32
from jinja2 .runtime import Context
34
35
from mkdocs .plugins import BasePlugin , event_priority
35
36
from mkdocs .structure import StructureItem
36
37
from mkdocs .structure .files import File , Files , InclusionLevel
37
- from mkdocs .structure .nav import Navigation , Section
38
+ from mkdocs .structure .nav import Link , Navigation , Section
38
39
from mkdocs .structure .pages import Page
40
+ from mkdocs .structure .toc import AnchorLink , TableOfContents
39
41
from mkdocs .utils import copy_file , get_relative_url
40
- from mkdocs .utils .templates import url_filter
41
42
from paginate import Page as Pagination
42
43
from shutil import rmtree
43
44
from tempfile import mkdtemp
45
+ from urllib .parse import urlparse
44
46
from yaml import SafeLoader
45
47
46
48
from .author import Authors
47
49
from .config import BlogConfig
48
50
from .readtime import readtime
49
- from .structure import Archive , Category , Excerpt , Post , View
51
+ from .structure import Archive , Category , Excerpt , Post , Reference , View
50
52
51
53
# -----------------------------------------------------------------------------
52
54
# Classes
@@ -299,10 +301,18 @@ def on_env(self, env, *, config, files):
299
301
if not self .config .enabled :
300
302
return
301
303
304
+ # Transform links to point to posts and pages
305
+ for post in self .blog .posts :
306
+ self ._generate_links (post , config , files )
307
+
302
308
# Filter for formatting dates related to posts
303
309
def date_filter (date : datetime ):
304
310
return self ._format_date_for_post (date , config )
305
311
312
+ # Fetch URL template filter from environment - the filter might
313
+ # be overridden by other plugins, so we must retrieve and wrap it
314
+ url_filter = env .filters ["url" ]
315
+
306
316
# Patch URL template filter to add support for paginated views, i.e.,
307
317
# that paginated views never link to themselves but to the main view
308
318
@pass_context
@@ -524,14 +534,15 @@ def _generate_archive(self, config: MkDocsConfig, files: Files):
524
534
525
535
# Create file for view, if it does not exist
526
536
file = files .get_file_from_path (path )
527
- if not file or self . temp_dir not in file . abs_src_path :
537
+ if not file :
528
538
file = self ._path_to_file (path , config )
529
539
files .append (file )
530
540
531
- # Create file in temporary directory and temporarily remove
532
- # from navigation, as we'll add it at a specific location
541
+ # Create file in temporary directory
533
542
self ._save_to_file (file .abs_src_path , f"# { name } " )
534
- file .inclusion = InclusionLevel .EXCLUDED
543
+
544
+ # Temporarily remove view from navigation
545
+ file .inclusion = InclusionLevel .EXCLUDED
535
546
536
547
# Create and yield view
537
548
if not isinstance (file .page , Archive ):
@@ -560,14 +571,15 @@ def _generate_categories(self, config: MkDocsConfig, files: Files):
560
571
561
572
# Create file for view, if it does not exist
562
573
file = files .get_file_from_path (path )
563
- if not file or self . temp_dir not in file . abs_src_path :
574
+ if not file :
564
575
file = self ._path_to_file (path , config )
565
576
files .append (file )
566
577
567
- # Create file in temporary directory and temporarily remove
568
- # from navigation, as we'll add it at a specific location
578
+ # Create file in temporary directory
569
579
self ._save_to_file (file .abs_src_path , f"# { name } " )
570
- file .inclusion = InclusionLevel .EXCLUDED
580
+
581
+ # Temporarily remove view from navigation
582
+ file .inclusion = InclusionLevel .EXCLUDED
571
583
572
584
# Create and yield view
573
585
if not isinstance (file .page , Category ):
@@ -591,14 +603,15 @@ def _generate_pages(self, view: View, config: MkDocsConfig, files: Files):
591
603
592
604
# Create file for view, if it does not exist
593
605
file = files .get_file_from_path (path )
594
- if not file or self . temp_dir not in file . abs_src_path :
606
+ if not file :
595
607
file = self ._path_to_file (path , config )
596
608
files .append (file )
597
609
598
- # Copy file to temporary directory and temporarily remove
599
- # from navigation, as we'll add it at a specific location
610
+ # Copy file to temporary directory
600
611
copy_file (view .file .abs_src_path , file .abs_src_path )
601
- file .inclusion = InclusionLevel .EXCLUDED
612
+
613
+ # Temporarily remove view from navigation
614
+ file .inclusion = InclusionLevel .EXCLUDED
602
615
603
616
# Create and yield view
604
617
if not isinstance (file .page , View ):
@@ -609,6 +622,79 @@ def _generate_pages(self, view: View, config: MkDocsConfig, files: Files):
609
622
file .page .pages = view .pages
610
623
file .page .posts = view .posts
611
624
625
+ # Generate links from the given post to other posts, pages, and sections -
626
+ # this can only be done once all posts and pages have been parsed
627
+ def _generate_links (self , post : Post , config : MkDocsConfig , files : Files ):
628
+ if not post .config .links :
629
+ return
630
+
631
+ # Resolve path relative to docs directory for error reporting
632
+ docs = os .path .relpath (config .docs_dir )
633
+ path = os .path .relpath (post .file .abs_src_path , docs )
634
+
635
+ # Find all links to pages and replace them with references - while all
636
+ # internal links are processed, external links remain as they are
637
+ for link in _find_links (post .config .links .items ):
638
+ url = urlparse (link .url )
639
+ if url .scheme :
640
+ continue
641
+
642
+ # Resolve file for link, and throw if the file could not be found -
643
+ # authors can link to other pages, as well as to assets or files of
644
+ # any kind, but it is essential that the file that is linked to is
645
+ # found, so errors are actually catched and reported
646
+ file = files .get_file_from_path (url .path )
647
+ if not file :
648
+ log .warning (
649
+ f"Error reading metadata of post '{ path } ' in '{ docs } ':\n "
650
+ f"Couldn't find file for link '{ url .path } '"
651
+ )
652
+ continue
653
+
654
+ # If the file linked to is not a page, but an asset or any other
655
+ # file, we resolve the destination URL and continue
656
+ if not isinstance (file .page , Page ):
657
+ link .url = file .url
658
+ continue
659
+
660
+ # Cast link to reference
661
+ link .__class__ = Reference
662
+ assert isinstance (link , Reference )
663
+
664
+ # Assign page title, URL and metadata to link
665
+ link .title = link .title or file .page .title
666
+ link .url = file .page .url
667
+ link .meta = copy (file .page .meta )
668
+
669
+ # If the link has no fragment, we can continue - if it does, we
670
+ # need to find the matching anchor in the table of contents
671
+ if not url .fragment :
672
+ continue
673
+
674
+ # If we're running under dirty reload, MkDocs will reset all pages,
675
+ # so it's not possible to resolve anchor links. Thus, the only way
676
+ # to make this work is to skip the entire process of anchor link
677
+ # resolution in case of a dirty reload.
678
+ if self .is_dirty :
679
+ continue
680
+
681
+ # Resolve anchor for fragment, and throw if the anchor could not be
682
+ # found - authors can link to any anchor in the table of contents
683
+ anchor = _find_anchor (file .page .toc , url .fragment )
684
+ if not anchor :
685
+ log .warning (
686
+ f"Error reading metadata of post '{ path } ' in '{ docs } ':\n "
687
+ f"Couldn't find anchor '{ url .fragment } ' in '{ url .path } '"
688
+ )
689
+
690
+ # Restore link to original state
691
+ link .url = url .geturl ()
692
+ continue
693
+
694
+ # Append anchor to URL and set subtitle
695
+ link .url += f"#{ anchor .id } "
696
+ link .meta ["subtitle" ] = anchor .title
697
+
612
698
# -------------------------------------------------------------------------
613
699
614
700
# Attach a list of pages to each other and to the given parent item without
@@ -864,6 +950,35 @@ def _translate(self, key: str, config: MkDocsConfig) -> str:
864
950
# Translate placeholder
865
951
return template .module .t (key )
866
952
953
+ # -----------------------------------------------------------------------------
954
+ # Helper functions
955
+ # -----------------------------------------------------------------------------
956
+
957
+ # Find all links in the given list of items
958
+ def _find_links (items : list [StructureItem ]):
959
+ for item in items :
960
+
961
+ # Resolve link
962
+ if isinstance (item , Link ):
963
+ yield item
964
+
965
+ # Resolve sections recursively
966
+ if isinstance (item , Section ):
967
+ for item in _find_links (item .children ):
968
+ assert isinstance (item , Link )
969
+ yield item
970
+
971
+ # Find anchor in table of contents for the given id
972
+ def _find_anchor (toc : TableOfContents , id : str ):
973
+ for anchor in toc :
974
+ if anchor .id == id :
975
+ return anchor
976
+
977
+ # Resolve anchors recursively
978
+ anchor = _find_anchor (anchor .children , id )
979
+ if isinstance (anchor , AnchorLink ):
980
+ return anchor
981
+
867
982
# -----------------------------------------------------------------------------
868
983
# Data
869
984
# -----------------------------------------------------------------------------
0 commit comments