Skip to content

Commit fefdd42

Browse files
committed
Merged features tied to 'Chipotle' funding goal
1 parent 07a434b commit fefdd42

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+6015
-346
lines changed

material/plugins/blog/plugin.py

+130-15
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import yaml
2727

2828
from babel.dates import format_date, format_datetime
29+
from copy import copy
2930
from datetime import datetime, timezone
3031
from jinja2 import pass_context
3132
from jinja2.runtime import Context
@@ -34,19 +35,20 @@
3435
from mkdocs.plugins import BasePlugin, event_priority
3536
from mkdocs.structure import StructureItem
3637
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
3839
from mkdocs.structure.pages import Page
40+
from mkdocs.structure.toc import AnchorLink, TableOfContents
3941
from mkdocs.utils import copy_file, get_relative_url
40-
from mkdocs.utils.templates import url_filter
4142
from paginate import Page as Pagination
4243
from shutil import rmtree
4344
from tempfile import mkdtemp
45+
from urllib.parse import urlparse
4446
from yaml import SafeLoader
4547

4648
from .author import Authors
4749
from .config import BlogConfig
4850
from .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
# -----------------------------------------------------------------------------

material/plugins/blog/structure/__init__.py

+26-1
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@
2727
from copy import copy
2828
from markdown import Markdown
2929
from material.plugins.blog.author import Author
30+
from material.plugins.meta.plugin import MetaPlugin
3031
from mkdocs.config.defaults import MkDocsConfig
3132
from mkdocs.exceptions import PluginError
3233
from mkdocs.structure.files import File, Files
33-
from mkdocs.structure.nav import Section
34+
from mkdocs.structure.nav import Link, Section
3435
from mkdocs.structure.pages import Page, _RelativePathTreeprocessor
3536
from mkdocs.structure.toc import get_toc
3637
from mkdocs.utils.meta import YAML_RE
@@ -87,6 +88,19 @@ def __init__(self, file: File, config: MkDocsConfig):
8788
f"{e}"
8889
)
8990

91+
# Hack: if the meta plugin is registered, we need to move the call
92+
# to `on_page_markdown` here, because we need to merge the metadata
93+
# of the post with the metadata of any meta files prior to creating
94+
# the post configuration. To our current knowledge, it's the only
95+
# way to allow posts to receive metadata from meta files, because
96+
# posts must be loaded prior to constructing the navigation in
97+
# `on_files` but the meta plugin first runs in `on_page_markdown`.
98+
plugin: MetaPlugin = config.plugins.get("material/meta")
99+
if plugin:
100+
plugin.on_page_markdown(
101+
self.markdown, page = self, config = config, files = None
102+
)
103+
90104
# Initialize post configuration, but remove all keys that this plugin
91105
# doesn't care about, or they will be reported as invalid configuration
92106
self.config: PostConfig = PostConfig(file.abs_src_path)
@@ -257,6 +271,17 @@ class Archive(View):
257271
class Category(View):
258272
pass
259273

274+
# -----------------------------------------------------------------------------
275+
276+
# Reference
277+
class Reference(Link):
278+
279+
# Initialize reference - this is essentially a crossover of pages and links,
280+
# as it inherits the metadata of the page and allows for anchors
281+
def __init__(self, title: str, url: str):
282+
super().__init__(title, url)
283+
self.meta = {}
284+
260285
# -----------------------------------------------------------------------------
261286
# Helper functions
262287
# -----------------------------------------------------------------------------

material/plugins/blog/structure/config.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,20 @@
1919
# IN THE SOFTWARE.
2020

2121
from mkdocs.config.base import Config
22-
from mkdocs.config.config_options import ListOfItems, Optional, Type
22+
from mkdocs.config.config_options import Optional, Type
2323

24-
from .options import PostDate
24+
from .options import PostDate, PostLinks, UniqueListOfItems
2525

2626
# -----------------------------------------------------------------------------
2727
# Classes
2828
# -----------------------------------------------------------------------------
2929

3030
# Post configuration
3131
class PostConfig(Config):
32-
authors = ListOfItems(Type(str), default = [])
33-
categories = ListOfItems(Type(str), default = [])
32+
authors = UniqueListOfItems(Type(str), default = [])
33+
categories = UniqueListOfItems(Type(str), default = [])
3434
date = PostDate()
3535
draft = Optional(Type(bool))
36+
links = Optional(PostLinks())
3637
readtime = Optional(Type(int))
3738
slug = Optional(Type(str))

material/plugins/blog/structure/markdown.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
1919
# IN THE SOFTWARE.
2020

21+
from __future__ import annotations
22+
2123
from markdown.treeprocessors import Treeprocessor
2224
from mkdocs.structure.pages import Page
2325
from mkdocs.utils import get_relative_url
@@ -31,12 +33,13 @@
3133
class ExcerptTreeprocessor(Treeprocessor):
3234

3335
# Initialize excerpt tree processor
34-
def __init__(self, page: Page, base: Page = None):
36+
def __init__(self, page: Page, base: Page | None = None):
3537
self.page = page
3638
self.base = base
3739

3840
# Transform HTML after Markdown processing
3941
def run(self, root: Element):
42+
assert self.base
4043
main = True
4144

4245
# We're only interested in anchors, which is why we continue when the

material/plugins/blog/structure/options.py

+29
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020

2121
from datetime import date, datetime, time, timezone
2222
from mkdocs.config.base import BaseConfigOption, Config, ValidationError
23+
from mkdocs.config.config_options import ListOfItems, T
24+
from mkdocs.structure.files import Files
25+
from mkdocs.structure.nav import (
26+
Navigation, _add_parent_links, _data_to_navigation
27+
)
2328
from typing import Dict
2429

2530
# -----------------------------------------------------------------------------
@@ -97,3 +102,27 @@ def run_validation(self, value: DateDict):
97102

98103
# Return date dictionary
99104
return value
105+
106+
# -----------------------------------------------------------------------------
107+
108+
# Post links option
109+
class PostLinks(BaseConfigOption[Navigation]):
110+
111+
# Create navigation from structured items - we don't need to provide a
112+
# configuration object to the function, because it will not be used
113+
def run_validation(self, value: object):
114+
items = _data_to_navigation(value, Files([]), None)
115+
_add_parent_links(items)
116+
117+
# Return navigation
118+
return Navigation(items, [])
119+
120+
# -----------------------------------------------------------------------------
121+
122+
# Unique list of items
123+
class UniqueListOfItems(ListOfItems[T]):
124+
125+
# Ensure that each item is unique
126+
def run_validation(self, value: object):
127+
data = super().run_validation(value)
128+
return list(dict.fromkeys(data))

material/plugins/meta/__init__.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright (c) 2016-2025 Martin Donath <[email protected]>
2+
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy
4+
# of this software and associated documentation files (the "Software"), to
5+
# deal in the Software without restriction, including without limitation the
6+
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
7+
# sell copies of the Software, and to permit persons to whom the Software is
8+
# furnished to do so, subject to the following conditions:
9+
10+
# The above copyright notice and this permission notice shall be included in
11+
# all copies or substantial portions of the Software.
12+
13+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
16+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
19+
# IN THE SOFTWARE.

0 commit comments

Comments
 (0)