2626import yaml
2727
2828from babel .dates import format_date , format_datetime
29+ from copy import copy
2930from datetime import datetime , timezone
3031from jinja2 import pass_context
3132from jinja2 .runtime import Context
3435from mkdocs .plugins import BasePlugin , event_priority
3536from mkdocs .structure import StructureItem
3637from mkdocs .structure .files import File , Files , InclusionLevel
37- from mkdocs .structure .nav import Navigation , Section
38+ from mkdocs .structure .nav import Link , Navigation , Section
3839from mkdocs .structure .pages import Page
40+ from mkdocs .structure .toc import AnchorLink , TableOfContents
3941from mkdocs .utils import copy_file , get_relative_url
40- from mkdocs .utils .templates import url_filter
4142from paginate import Page as Pagination
4243from shutil import rmtree
4344from tempfile import mkdtemp
45+ from urllib .parse import urlparse
4446from yaml import SafeLoader
4547
4648from .author import Authors
4749from .config import BlogConfig
4850from .readtime import readtime
49- from .structure import Archive , Category , Excerpt , Post , View
51+ from .structure import Archive , Category , Excerpt , Post , Reference , View
5052
5153# -----------------------------------------------------------------------------
5254# Classes
@@ -299,10 +301,18 @@ def on_env(self, env, *, config, files):
299301 if not self .config .enabled :
300302 return
301303
304+ # Transform links to point to posts and pages
305+ for post in self .blog .posts :
306+ self ._generate_links (post , config , files )
307+
302308 # Filter for formatting dates related to posts
303309 def date_filter (date : datetime ):
304310 return self ._format_date_for_post (date , config )
305311
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+
306316 # Patch URL template filter to add support for paginated views, i.e.,
307317 # that paginated views never link to themselves but to the main view
308318 @pass_context
@@ -524,14 +534,15 @@ def _generate_archive(self, config: MkDocsConfig, files: Files):
524534
525535 # Create file for view, if it does not exist
526536 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 :
528538 file = self ._path_to_file (path , config )
529539 files .append (file )
530540
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
533542 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
535546
536547 # Create and yield view
537548 if not isinstance (file .page , Archive ):
@@ -560,14 +571,15 @@ def _generate_categories(self, config: MkDocsConfig, files: Files):
560571
561572 # Create file for view, if it does not exist
562573 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 :
564575 file = self ._path_to_file (path , config )
565576 files .append (file )
566577
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
569579 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
571583
572584 # Create and yield view
573585 if not isinstance (file .page , Category ):
@@ -591,14 +603,15 @@ def _generate_pages(self, view: View, config: MkDocsConfig, files: Files):
591603
592604 # Create file for view, if it does not exist
593605 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 :
595607 file = self ._path_to_file (path , config )
596608 files .append (file )
597609
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
600611 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
602615
603616 # Create and yield view
604617 if not isinstance (file .page , View ):
@@ -609,6 +622,79 @@ def _generate_pages(self, view: View, config: MkDocsConfig, files: Files):
609622 file .page .pages = view .pages
610623 file .page .posts = view .posts
611624
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+
612698 # -------------------------------------------------------------------------
613699
614700 # 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:
864950 # Translate placeholder
865951 return template .module .t (key )
866952
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+
867982# -----------------------------------------------------------------------------
868983# Data
869984# -----------------------------------------------------------------------------
0 commit comments