mirror of
https://github.com/squidfunk/mkdocs-material.git
synced 2026-03-31 06:24:07 -04:00
Prepare 9.7.0 release
This commit is contained in:
40
CHANGELOG
40
CHANGELOG
@@ -1,3 +1,43 @@
|
||||
mkdocs-material-9.7.0 (2025-11-11)
|
||||
|
||||
⚠️ Material for MkDocs is now in maintenance mode
|
||||
|
||||
This is the last release of Material for MkDocs that will receive new features.
|
||||
Going forward, the Material for MkDocs team focuses on Zensical, a next-gen
|
||||
static site generator built from first principles. We will provide critical
|
||||
bug fixes and security updates for Material for MkDocs for 12 months – at least.
|
||||
|
||||
Read the full announcement on our blog:
|
||||
https://squidfunk.github.io/mkdocs-material/blog/2025/11/05/zensical/
|
||||
|
||||
This release includes all features that were previously exclusively available
|
||||
as part of the Insiders edition. They are now free for everybody to use. The
|
||||
projects and typeset plugins turned out to be dead ends, which is why they
|
||||
are deprecated and not part of Material for MkDocs. The sources of those plugins
|
||||
are distributed with the project, and need to be re-enabled in pyproject.toml,
|
||||
so third party contributors can take on maintenance, if desired.
|
||||
|
||||
Changes:
|
||||
|
||||
- Added support for pinned blog posts and author profiles
|
||||
- Added support for customizing pagination for blog index pages
|
||||
- Added support for customizing blog category sort order
|
||||
- Added support for staying on page when switching languages
|
||||
- Added support for disabling tags in table of contents
|
||||
- Added support for nested tags and shadow tags
|
||||
- Added support for footnote tooltips
|
||||
- Added support for instant previews
|
||||
- Added support for instant prefetching
|
||||
- Added support for custom social card layouts
|
||||
- Added support for custom social card background images
|
||||
- Added support for selectable rangs in code blocks
|
||||
- Added support for custom selectors for code annotations
|
||||
- Added support for configurable log level in privacy plugin
|
||||
- Added support for processing of external links in privacy plugin
|
||||
- Added support for automatic image optimization via optimize plugin
|
||||
- Added support for navigation paths (breadcrumbs)
|
||||
- Fixed #8519: Vector accents do not render when using KaTeX
|
||||
|
||||
mkdocs-material-9.6.23 (2025-11-01)
|
||||
|
||||
* Updated Burmese translation
|
||||
|
||||
@@ -2,6 +2,50 @@
|
||||
|
||||
## Material for MkDocs
|
||||
|
||||
### 9.7.0 <small>November 11, 2025</small> { id="9.7.0" }
|
||||
|
||||
⚠️ __Material for MkDocs is now in maintenance mode__
|
||||
|
||||
This is the last release of Material for MkDocs that will receive new features.
|
||||
Going forward, the Material for MkDocs team focuses on [Zensical], a next-gen
|
||||
static site generator built from first principles. We will provide critical
|
||||
bug fixes and security updates for Material for MkDocs for 12 months – at least.
|
||||
|
||||
[Read the full announcement on our blog]
|
||||
|
||||
This release includes all features that were previously exclusively available
|
||||
as part of the Insiders edition. They are now free for everybody to use. The
|
||||
[projects] and [typeset] plugins turned out to be dead ends, which is why they
|
||||
are deprecated and not part of Material for MkDocs. The sources of those plugins
|
||||
are distributed with the project, and need to be re-enabled in `pyproject.toml`,
|
||||
so third party contributors can take on maintenance, if desired.
|
||||
|
||||
__Changes__:
|
||||
|
||||
- Added support for pinned blog posts and author profiles
|
||||
- Added support for customizing pagination for blog index pages
|
||||
- Added support for customizing blog category sort order
|
||||
- Added support for staying on page when switching languages
|
||||
- Added support for disabling tags in table of contents
|
||||
- Added support for nested tags and shadow tags
|
||||
- Added support for footnote tooltips
|
||||
- Added support for instant previews
|
||||
- Added support for instant prefetching
|
||||
- Added support for custom social card layouts
|
||||
- Added support for custom social card background images
|
||||
- Added support for selectable rangs in code blocks
|
||||
- Added support for custom selectors for code annotations
|
||||
- Added support for configurable log level in privacy plugin
|
||||
- Added support for processing of external links in privacy plugin
|
||||
- Added support for automatic image optimization via optimize plugin
|
||||
- Added support for navigation paths (breadcrumbs)
|
||||
- Fixed #8519: Vector accents do not render when using KaTeX
|
||||
|
||||
[Zensical]: https://zensical.org
|
||||
[Read the full announcement on our blog]: https://squidfunk.github.io/mkdocs-material/blog/2025/11/05/zensical/
|
||||
[projects]: https://squidfunk.github.io/mkdocs-material/plugins/projects/
|
||||
[typeset]: https://squidfunk.github.io/mkdocs-material/plugins/typeset/
|
||||
|
||||
### 9.6.23 <small>November 1, 2025</small> { id="9.6.23" }
|
||||
|
||||
- Updated Burmese translation
|
||||
|
||||
@@ -18,4 +18,4 @@
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
__version__ = "9.6.23"
|
||||
__version__ = "9.7.0"
|
||||
|
||||
223
material/extensions/preview.py
Normal file
223
material/extensions/preview.py
Normal file
@@ -0,0 +1,223 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from material.utilities.filter import FileFilter, FilterConfig
|
||||
from mkdocs.structure.pages import _RelativePathTreeprocessor
|
||||
from markdown import Extension, Markdown
|
||||
from markdown.treeprocessors import Treeprocessor
|
||||
from mkdocs.exceptions import ConfigurationError
|
||||
from urllib.parse import urlparse
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class PreviewProcessor(Treeprocessor):
|
||||
"""
|
||||
A Markdown treeprocessor to enable instant previews on links.
|
||||
|
||||
Note that this treeprocessor is dependent on the `relpath` treeprocessor
|
||||
registered programmatically by MkDocs before rendering a page.
|
||||
"""
|
||||
|
||||
def __init__(self, md: Markdown, config: dict):
|
||||
"""
|
||||
Initialize the treeprocessor.
|
||||
|
||||
Arguments:
|
||||
md: The Markdown instance.
|
||||
config: The configuration.
|
||||
"""
|
||||
super().__init__(md)
|
||||
self.config = config
|
||||
|
||||
def run(self, root: Element):
|
||||
"""
|
||||
Run the treeprocessor.
|
||||
|
||||
Arguments:
|
||||
root: The root element of the parsed Markdown document.
|
||||
"""
|
||||
at = self.md.treeprocessors.get_index_for_name("relpath")
|
||||
|
||||
# Hack: Python Markdown has no notion of where it is, i.e., which file
|
||||
# is being processed. This seems to be a deliberate design decision, as
|
||||
# it is not possible to access the file path of the current page, but
|
||||
# it might also be an oversight that is now impossible to fix. However,
|
||||
# since this extension is only useful in the context of Material for
|
||||
# MkDocs, we can assume that the _RelativePathTreeprocessor is always
|
||||
# present, telling us the file path of the current page. If that ever
|
||||
# changes, we would need to wrap this extension in a plugin, but for
|
||||
# the time being we are sneaky and will probably get away with it.
|
||||
processor = self.md.treeprocessors[at]
|
||||
if not isinstance(processor, _RelativePathTreeprocessor):
|
||||
raise TypeError("Relative path processor not registered")
|
||||
|
||||
# Normalize configurations
|
||||
configurations = self.config["configurations"]
|
||||
configurations.append({
|
||||
"sources": self.config.get("sources"),
|
||||
"targets": self.config.get("targets")
|
||||
})
|
||||
|
||||
# Walk through all configurations - @todo refactor so that we don't
|
||||
# iterate multiple times over the same elements
|
||||
for configuration in configurations:
|
||||
|
||||
# Skip, if the configuration defines nothing – we could also fix
|
||||
# this in the file filter, but we first fix it here and check if
|
||||
# it generalizes well enough to other inclusion/exclusion sites,
|
||||
# because here, it would hinder the ability to automaticaly
|
||||
# include all sources, while excluding specific targets.
|
||||
if (
|
||||
not configuration.get("sources") and
|
||||
not configuration.get("targets")
|
||||
):
|
||||
continue
|
||||
|
||||
# Skip if page should not be considered
|
||||
filter = get_filter(configuration, "sources")
|
||||
if not filter(processor.file):
|
||||
continue
|
||||
|
||||
# Walk through all links and add preview attributes
|
||||
filter = get_filter(configuration, "targets")
|
||||
for el in root.iter("a"):
|
||||
href = el.get("href")
|
||||
if not href:
|
||||
continue
|
||||
|
||||
# Skip footnotes
|
||||
if "footnote-ref" in el.get("class", ""):
|
||||
continue
|
||||
|
||||
# Skip external links
|
||||
url = urlparse(href)
|
||||
if url.scheme or url.netloc:
|
||||
continue
|
||||
|
||||
# Add preview attribute to internal links
|
||||
for path in processor._possible_target_uris(
|
||||
processor.file, url.path,
|
||||
processor.config.use_directory_urls
|
||||
):
|
||||
target = processor.files.get_file_from_path(path)
|
||||
if not target:
|
||||
continue
|
||||
|
||||
# Include, if filter matches
|
||||
if filter(target):
|
||||
el.set("data-preview", "")
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class PreviewExtension(Extension):
|
||||
"""
|
||||
A Markdown extension to enable instant previews on links.
|
||||
|
||||
This extensions allows to automatically add the `data-preview` attribute to
|
||||
internal links matching specific criteria, so Material for MkDocs renders a
|
||||
nice preview on hover as part of a tooltip. It is the recommended way to
|
||||
add previews to links in a programmatic way.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
"""
|
||||
self.config = {
|
||||
"configurations": [[], "Filter configurations"],
|
||||
"sources": [{}, "Link sources"],
|
||||
"targets": [{}, "Link targets"]
|
||||
}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def extendMarkdown(self, md: Markdown):
|
||||
"""
|
||||
Register Markdown extension.
|
||||
|
||||
Arguments:
|
||||
md: The Markdown instance.
|
||||
"""
|
||||
md.registerExtension(self)
|
||||
|
||||
# Create and register treeprocessor - we use the same priority as the
|
||||
# `relpath` treeprocessor, the latter of which is guaranteed to run
|
||||
# after our treeprocessor, so we can check the original Markdown URIs
|
||||
# before they are resolved to URLs.
|
||||
processor = PreviewProcessor(md, self.getConfigs())
|
||||
md.treeprocessors.register(processor, "preview", 0)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def get_filter(settings: dict, key: str):
|
||||
"""
|
||||
Get file filter from settings.
|
||||
|
||||
Arguments:
|
||||
settings: The settings.
|
||||
key: The key in the settings.
|
||||
|
||||
Returns:
|
||||
The file filter.
|
||||
"""
|
||||
config = FilterConfig()
|
||||
config.load_dict(settings.get(key) or {})
|
||||
|
||||
# Validate filter configuration
|
||||
errors, warnings = config.validate()
|
||||
for _, w in warnings:
|
||||
log.warning(
|
||||
f"Error reading filter configuration in '{key}':\n"
|
||||
f"{w}"
|
||||
)
|
||||
for _, e in errors:
|
||||
raise ConfigurationError(
|
||||
f"Error reading filter configuration in '{key}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Return file filter
|
||||
return FileFilter(config = config) # type: ignore
|
||||
|
||||
def makeExtension(**kwargs):
|
||||
"""
|
||||
Register Markdown extension.
|
||||
|
||||
Arguments:
|
||||
**kwargs: Configuration options.
|
||||
|
||||
Returns:
|
||||
The Markdown extension.
|
||||
"""
|
||||
return PreviewExtension(**kwargs)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.extensions.preview")
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"version":3,"sources":["src/overrides/assets/stylesheets/custom/_typeset.scss","../../../../src/overrides/assets/stylesheets/custom.scss","src/templates/assets/stylesheets/utilities/_break.scss","src/overrides/assets/stylesheets/custom/layout/_banner.scss","src/overrides/assets/stylesheets/custom/layout/_hero.scss","src/overrides/assets/stylesheets/custom/layout/_iconsearch.scss","src/overrides/assets/stylesheets/custom/layout/_sponsorship.scss"],"names":[],"mappings":"AA2BA,iBACE,cAIE,kBC7BF,CDgCA,QAEE,qBC/BF,CACF,CD0CE,qBACE,aCxCJ,CD6CE,sBACE,aC3CJ,CD+CE,uBACE,UC7CJ,CDgDI,8BAGE,QAAA,CACA,sBAAA,CAHA,iBAAA,CACA,UC5CN,CDkDI,8BAOE,WAAA,CAFA,WAAA,CAFA,MAAA,CAGA,eAAA,CALA,iBAAA,CACA,KAAA,CAEA,UC7CN,CDqDE,uBACE,2BCnDJ,CDuDE,0BACE,aCrDJ,CD2DE,uBACE,eCzDJ,CD4DI,8BACE,4BAAA,CACA,4BAAA,CACA,2CAAA,CAEA,aC3DN,CD8DM,uCACE,2BC5DR,CDiEI,8BACE,WAAA,CACA,iBC/DN,CDmEI,uCAGE,4BChEN,CD6DI,uCAGE,6BChEN,CD6DI,uCAIE,+BCjEN,CD6DI,uCAIE,gCCjEN,CD6DI,6BAEE,iDAAA,CADA,aC9DN,CDoEM,wCACE,mBClER,CDuEI,uCAEE,6BCnEN,CDiEI,uCAEE,4BCnEN,CDiEI,uCAGE,gCCpEN,CDiEI,uCAGE,+BCpEN,CDiEI,6BAIE,iEAAA,CAHA,mBClEN,CD4EE,+BACE,cAAA,CACA,uBC1EJ,CD6EI,0EACE,WC3EN,CD+EI,oCAGE,2CAAA,CADA,gCAAA,CADA,aC3EN,CDqFI,wDAEE,cCnFN,CCgII,0CF/CA,wDAMI,eClFN,CACF,CDsFI,4BACE,kBCpFN,CDyFE,uBACE,eCvFJ,CD0FI,0BACE,eCxFN,CD2FM,6BACE,iBCzFR,CD8FI,6BACE,YAAA,CACA,SC5FN,CDgGI,gCACE,YAAA,CACA,MAAA,CACA,qBC9FN,CDiGM,qCAEE,oBAAA,CADA,mBAAA,CAEA,6BC/FR,CDmGM,kDACE,aCjGR,CDqGM,qCACE,WCnGR,CDyGE,wBAEE,sBAAA,CADA,iBCtGJ,CD0GI,iDACE,0BCxGN,CD4GI,+BAEE,eAAA,CADA,iBAAA,CAGA,2BAAA,CADA,uCCzGN,CDgHQ,wDACE,SC9GV,CDkHQ,wDACE,0BChHV,CDoHQ,wDACE,SClHV,CDwHI,+BACE,yCACE,CAGF,oDAAA,CADA,mBCvHN,CD2HM,mCACE,aCzHR,CD8HI,+BAKE,kDAAA,CADA,gCAAA,CAFA,aAAA,CAIA,SAAA,CAHA,mBAAA,CAFA,iBAAA,CAMA,mBC5HN,CDiIM,8DACE,2BC/HR,CD8HM,8DACE,2BC5HR,CD2HM,8DACE,2BCzHR,CDwHM,8DACE,uBCtHR,CDqHM,8DACE,0BCnHR,CDkHM,6DACE,0BChHR,CD+GM,8DACE,0BC7GR,CE3JA,WACE,wCF8JF,CE3JE,kBAEE,kBF6JJ,CE1JE,+BAJE,+BFiKJ,CE1JI,sCAEE,kBF2JN,CEzJM,wDACE,0CAAA,CACA,eF2JR,CEtJE,oBAME,kBAAA,CACA,0CAAA,CANA,oBAAA,CAEA,aAAA,CACA,cAAA,CAIA,mBAAA,CAHA,qBAAA,CAHA,YF8JJ,CEtJI,wBACE,aAAA,CACA,eFwJN,CG3LA,eAEE,uYACE,CAFF,gBH+LF,CGpLE,4CACE,yYHsLJ,CG1KA,UAEE,gCAAA,CADA,cH8KF,CG1KE,aAGE,kBAAA,CADA,eAAA,CADA,kBH8KJ,CCpBI,0CE3JF,aAOI,gBH4KJ,CACF,CGxKE,mBACE,mBH0KJ,CC/CI,mCE7IJ,UAwBI,mBAAA,CADA,YH0KF,CGtKE,mBAGE,iBAAA,CAFA,eAAA,CACA,mBHyKJ,CGpKE,iBACE,OAAA,CAEA,0BAAA,CADA,WHuKJ,CACF,CC/DI,sCEhGA,iBACE,0BHkKJ,CACF,CG9JE,qBAGE,gCAAA,CADA,kBAAA,CADA,gBHkKJ,CG7JI,sDAEE,0CAAA,CACA,sCAAA,CAFA,+BHiKN,CG3JI,8BAEE,2CAAA,CACA,uCAAA,CAFA,aH+JN,CItPE,4BAEE,2CAAA,CACA,mBAAA,CACA,8BAAA,CAHA,iBAAA,CAIA,2BJyPJ,CItPI,2EACE,8BJwPN,CIpPI,sCACE,qCAAA,CACA,eJsPN,CInPM,mEACE,kCJqPR,CI/OE,mCAIE,kCAAA,CAAA,0BAAA,CAHA,eAAA,CACA,eAAA,CAIA,yDAAA,CACA,oBAAA,CAFA,kBJkPJ,CI7OI,+CACE,mBJ+ON,CI3OI,sDAEE,YAAA,CADA,WJ8ON,CIzOI,4DACE,oDJ2ON,CIxOM,kEACE,0CJ0OR,CIrOI,yCAKE,yCAAA,CADA,gBAAA,CAHA,iBAAA,CAEA,WAAA,CADA,SJ0ON,CC9GI,0CG9HA,yCASI,YJuON,CACF,CInOI,2CAOE,qDAAA,CACA,WAAA,CACA,mBAAA,CAHA,uCAAA,CADA,gBAAA,CADA,oBAAA,CAAA,iBAAA,CAHA,iBAAA,CAEA,WAAA,CADA,SAAA,CAQA,6CJqON,CIlOM,kGAGE,0CAAA,CADA,+BAAA,CAEA,YJmOR,CI/NM,wEACE,YJiOR,CI5NI,mDAKE,aJ6NN,CIlOI,mDAKE,cJ6NN,CIlOI,yCAME,eAAA,CAJA,QAAA,CADA,SJiON,CIxNI,mDAKE,aJyNN,CI9NI,mDAKE,cJyNN,CI9NI,yCAME,+DAAA,CAJA,QAAA,CADA,mBJ6NN,CIrNM,oDACE,kBJuNR,CInNM,2CACE,kBJqNR,CIjNM,6CAEE,YAAA,CADA,WJoNR,CIhNQ,0FACE,gBJkNV,CKnVI,2BACE,YAAA,CACA,iBLsVN,CKlVI,6BACE,cLoVN,CKhVI,sCACE,YAAA,CACA,cAAA,CACA,sBLkVN,CK/UM,wCACE,aAAA,CACA,aLiVR,CKxUI,mCACE,YL0UN,CKvUM,yCAEE,UAAA,CACA,UAAA,CAFA,aL2UR,CKpUI,6CAEE,UL6UN,CK/UI,6CAEE,WL6UN,CK/UI,mCAOE,kBAAA,CANA,aAAA,CAGA,aAAA,CACA,YAAA,CACA,eAAA,CAKA,kBAAA,CAHA,sCACE,CANF,YL4UN,CKjUM,kFACE,oBLmUR,CKhUQ,0FACE,mBLkUV,CK7TM,4CAME,+CAAA,CAFA,yCAAA,CAHA,eAAA,CACA,eAAA,CACA,kBAAA,CAEA,iBLgUR,CK3TM,uCACE,aAAA,CAGA,mCAAA,CADA,WAAA,CAEA,uBAAA,CAHA,ULgUR,CKvTE,oCACE,eLyTJ,CKrTE,sEAEE,eLuTJ","file":"custom.css"}
|
||||
@@ -3,7 +3,7 @@
|
||||
-#}
|
||||
{% extends "base.html" %}
|
||||
{% block extrahead %}
|
||||
<link rel="stylesheet" href="{{ 'assets/stylesheets/custom.a2a27114.min.css' | url }}">
|
||||
<link rel="stylesheet" href="{{ 'assets/stylesheets/custom.css' | url }}">
|
||||
{% endblock %}
|
||||
{% block announce %}
|
||||
For updates follow <strong>@squidfunk</strong> on
|
||||
@@ -23,5 +23,5 @@
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script src="{{ 'assets/javascripts/custom.95054b18.min.js' | url }}"></script>
|
||||
<script src="{{ 'assets/javascripts/custom.js' | url }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,3 +17,17 @@
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from .structure import View
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Sort views by name
|
||||
def view_name(view: View):
|
||||
return view.name
|
||||
|
||||
# Sort views by post count
|
||||
def view_post_count(view: View):
|
||||
return len(view.posts)
|
||||
|
||||
@@ -23,6 +23,8 @@ from mkdocs.config.config_options import Choice, Deprecated, Optional, Type
|
||||
from mkdocs.config.base import Config
|
||||
from pymdownx.slugs import slugify
|
||||
|
||||
from . import view_name
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -56,6 +58,8 @@ class BlogConfig(Config):
|
||||
archive_date_format = Type(str, default = "yyyy")
|
||||
archive_url_date_format = Type(str, default = "yyyy")
|
||||
archive_url_format = Type(str, default = "archive/{date}")
|
||||
archive_pagination = Optional(Type(bool))
|
||||
archive_pagination_per_page = Optional(Type(int))
|
||||
archive_toc = Optional(Type(bool))
|
||||
|
||||
# Settings for categories
|
||||
@@ -64,12 +68,22 @@ class BlogConfig(Config):
|
||||
categories_url_format = Type(str, default = "category/{slug}")
|
||||
categories_slugify = Type(Callable, default = slugify(case = "lower"))
|
||||
categories_slugify_separator = Type(str, default = "-")
|
||||
categories_sort_by = Type(Callable, default = view_name)
|
||||
categories_sort_reverse = Type(bool, default = False)
|
||||
categories_allowed = Type(list, default = [])
|
||||
categories_pagination = Optional(Type(bool))
|
||||
categories_pagination_per_page = Optional(Type(int))
|
||||
categories_toc = Optional(Type(bool))
|
||||
|
||||
# Settings for authors
|
||||
authors = Type(bool, default = True)
|
||||
authors_file = Type(str, default = "{blog}/.authors.yml")
|
||||
authors_profiles = Type(bool, default = False)
|
||||
authors_profiles_name = Type(str, default = "blog.authors")
|
||||
authors_profiles_url_format = Type(str, default = "author/{slug}")
|
||||
authors_profiles_pagination = Optional(Type(bool))
|
||||
authors_profiles_pagination_per_page = Optional(Type(int))
|
||||
authors_profiles_toc = Optional(Type(bool))
|
||||
|
||||
# Settings for pagination
|
||||
pagination = Type(bool, default = True)
|
||||
|
||||
@@ -45,10 +45,15 @@ from tempfile import mkdtemp
|
||||
from urllib.parse import urlparse
|
||||
from yaml import SafeLoader
|
||||
|
||||
from .author import Authors
|
||||
from . import view_name
|
||||
from .author import Author, Authors
|
||||
from .config import BlogConfig
|
||||
from .readtime import readtime
|
||||
from .structure import Archive, Category, Excerpt, Post, Reference, View
|
||||
from .structure import (
|
||||
Archive, Category, Profile,
|
||||
Excerpt, Post, View,
|
||||
Reference
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
@@ -86,12 +91,6 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
if self.config.authors:
|
||||
self.authors = self._resolve_authors(config)
|
||||
|
||||
# Initialize table of contents settings
|
||||
if not isinstance(self.config.archive_toc, bool):
|
||||
self.config.archive_toc = self.config.blog_toc
|
||||
if not isinstance(self.config.categories_toc, bool):
|
||||
self.config.categories_toc = self.config.blog_toc
|
||||
|
||||
# By default, drafts are rendered when the documentation is served,
|
||||
# but not when it is built, for a better user experience
|
||||
if self.is_serve and self.config.draft_on_serve:
|
||||
@@ -134,27 +133,40 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
self.blog = self._resolve(files, config)
|
||||
self.blog.posts = sorted(
|
||||
self._resolve_posts(files, config),
|
||||
key = lambda post: post.config.date.created,
|
||||
key = lambda post: (
|
||||
post.config.pin,
|
||||
post.config.date.created
|
||||
),
|
||||
reverse = True
|
||||
)
|
||||
|
||||
# Generate views for archive
|
||||
if self.config.archive:
|
||||
self.blog.views.extend(
|
||||
self._generate_archive(config, files)
|
||||
)
|
||||
views = self._generate_archive(config, files)
|
||||
self.blog.views.extend(views)
|
||||
|
||||
# Generate views for categories
|
||||
if self.config.categories:
|
||||
views = self._generate_categories(config, files)
|
||||
|
||||
# We always sort the list of categories by name first, so that any
|
||||
# custom sorting function that returns the same value for two items
|
||||
# returns them in a predictable and logical order, because sorting
|
||||
# in Python is stable, i.e., order of equal items is preserved
|
||||
self.blog.views.extend(sorted(
|
||||
self._generate_categories(config, files),
|
||||
key = lambda view: view.name,
|
||||
reverse = False
|
||||
sorted(views, key = view_name),
|
||||
key = self.config.categories_sort_by,
|
||||
reverse = self.config.categories_sort_reverse
|
||||
))
|
||||
|
||||
# Generate views for profiles
|
||||
if self.config.authors_profiles:
|
||||
views = self._generate_profiles(config, files)
|
||||
self.blog.views.extend(views)
|
||||
|
||||
# Generate pages for views
|
||||
if self.config.pagination:
|
||||
for view in self._resolve_views(self.blog):
|
||||
for view in self._resolve_views(self.blog):
|
||||
if self._config_pagination(view):
|
||||
for page in self._generate_pages(view, config, files):
|
||||
view.pages.append(page)
|
||||
|
||||
@@ -209,9 +221,18 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
if self.blog.file.inclusion.is_in_nav() and views:
|
||||
self._attach_to(self.blog, Section(title, views), nav)
|
||||
|
||||
# Attach views for profiles
|
||||
if self.config.authors_profiles:
|
||||
title = self._translate(self.config.authors_profiles_name, config)
|
||||
views = [_ for _ in self.blog.views if isinstance(_, Profile)]
|
||||
|
||||
# Attach and link views for categories, if any
|
||||
if self.blog.file.inclusion.is_in_nav() and views:
|
||||
self._attach_to(self.blog, Section(title, views), nav)
|
||||
|
||||
# Attach pages for views
|
||||
if self.config.pagination:
|
||||
for view in self._resolve_views(self.blog):
|
||||
for view in self._resolve_views(self.blog):
|
||||
if self._config_pagination(view):
|
||||
for at in range(1, len(view.pages)):
|
||||
self._attach_at(view.parent, view, view.pages[at])
|
||||
|
||||
@@ -227,7 +248,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
# Skip if page is not a post managed by this instance - this plugin has
|
||||
# support for multiple instances, which is why this check is necessary
|
||||
if page not in self.blog.posts:
|
||||
if not self.config.pagination:
|
||||
if not self._config_pagination(page):
|
||||
return
|
||||
|
||||
# We set the contents of the view to its title if pagination should
|
||||
@@ -250,12 +271,12 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
|
||||
# Extract and assign authors to post, if enabled
|
||||
if self.config.authors:
|
||||
for name in page.config.authors:
|
||||
if name not in self.authors:
|
||||
raise PluginError(f"Couldn't find author '{name}'")
|
||||
for id in page.config.authors:
|
||||
if id not in self.authors:
|
||||
raise PluginError(f"Couldn't find author '{id}'")
|
||||
|
||||
# Append to list of authors
|
||||
page.authors.append(self.authors[name])
|
||||
page.authors.append(self.authors[id])
|
||||
|
||||
# Extract settings for excerpts
|
||||
separator = self.config.post_excerpt_separator
|
||||
@@ -314,7 +335,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
url_filter = env.filters["url"]
|
||||
|
||||
# Patch URL template filter to add support for paginated views, i.e.,
|
||||
# that paginated views never link to themselves but to the main view
|
||||
# that paginated views never link to themselves but to the main vie
|
||||
@pass_context
|
||||
def url_filter_with_pagination(context: Context, url: str | None):
|
||||
page = context["page"]
|
||||
@@ -590,6 +611,37 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
file.page.posts.append(post)
|
||||
post.categories.append(file.page)
|
||||
|
||||
# Generate views for profiles - analyze posts and generate the necessary
|
||||
# views to provide a profile page for each author listing all posts
|
||||
def _generate_profiles(self, config: MkDocsConfig, files: Files):
|
||||
for post in self.blog.posts:
|
||||
for id in post.config.authors:
|
||||
author = self.authors[id]
|
||||
path = self._format_path_for_profile(id, author)
|
||||
|
||||
# Create file for view, if it does not exist
|
||||
file = files.get_file_from_path(path)
|
||||
if not file:
|
||||
file = self._path_to_file(path, config)
|
||||
files.append(file)
|
||||
|
||||
# Create file in temporary directory
|
||||
self._save_to_file(file.abs_src_path, f"# {author.name}")
|
||||
|
||||
# Temporarily remove view from navigation and assign profile
|
||||
# URL to author, if not explicitly set
|
||||
file.inclusion = InclusionLevel.EXCLUDED
|
||||
if not author.url:
|
||||
author.url = file.url
|
||||
|
||||
# Create and yield view
|
||||
if not isinstance(file.page, Profile):
|
||||
yield Profile(author.name, file, config)
|
||||
|
||||
# Assign post to profile
|
||||
assert isinstance(file.page, Profile)
|
||||
file.page.posts.append(post)
|
||||
|
||||
# Generate pages for pagination - analyze view and generate the necessary
|
||||
# pages, creating a chain of views for simple rendering and replacement
|
||||
def _generate_pages(self, view: View, config: MkDocsConfig, files: Files):
|
||||
@@ -597,7 +649,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
|
||||
# Compute pagination boundaries and create pages - pages are internally
|
||||
# handled as copies of a view, as they map to the same source location
|
||||
step = self.config.pagination_per_page
|
||||
step = self._config_pagination_per_page(view)
|
||||
for at in range(step, len(view.posts), step):
|
||||
path = self._format_path_for_pagination(view, 1 + at // step)
|
||||
|
||||
@@ -747,11 +799,11 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
posts, pagination = view.posts, None
|
||||
|
||||
# Create pagination, if enabled
|
||||
if self.config.pagination:
|
||||
if self._config_pagination(view):
|
||||
at = view.pages.index(view)
|
||||
|
||||
# Compute pagination boundaries
|
||||
step = self.config.pagination_per_page
|
||||
step = self._config_pagination_per_page(view)
|
||||
p, q = at * step, at * step + step
|
||||
|
||||
# Extract posts in pagination boundaries
|
||||
@@ -771,18 +823,9 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
def _render_post(self, excerpt: Excerpt, view: View):
|
||||
excerpt.render(view, self.config.post_excerpt_separator)
|
||||
|
||||
# Determine whether to add posts to the table of contents of the view -
|
||||
# note that those settings can be changed individually for each type of
|
||||
# view, which is why we need to check the type of view and the table of
|
||||
# contents setting for that type of view
|
||||
toc = self.config.blog_toc
|
||||
if isinstance(view, Archive):
|
||||
toc = self.config.archive_toc
|
||||
if isinstance(view, Category):
|
||||
toc = self.config.categories_toc
|
||||
|
||||
# Attach top-level table of contents item to view if it should be added
|
||||
# and both, the view and excerpt contain table of contents items
|
||||
toc = self._config_toc(view)
|
||||
if toc and excerpt.toc.items and view.toc.items:
|
||||
view.toc.items[0].children.append(excerpt.toc.items[0])
|
||||
|
||||
@@ -806,6 +849,48 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Retrieve configuration value or return default
|
||||
def _config(self, key: str, default: any):
|
||||
return default if self.config[key] is None else self.config[key]
|
||||
|
||||
# Retrieve configuration value for table of contents
|
||||
def _config_toc(self, view: View):
|
||||
default = self.config.blog_toc
|
||||
if isinstance(view, Archive):
|
||||
return self._config("archive_toc", default)
|
||||
if isinstance(view, Category):
|
||||
return self._config("categories_toc", default)
|
||||
if isinstance(view, Profile):
|
||||
return self._config("authors_profiles_toc", default)
|
||||
else:
|
||||
return default
|
||||
|
||||
# Retrieve configuration value for pagination
|
||||
def _config_pagination(self, view: View):
|
||||
default = self.config.pagination
|
||||
if isinstance(view, Archive):
|
||||
return self._config("archive_pagination", default)
|
||||
if isinstance(view, Category):
|
||||
return self._config("categories_pagination", default)
|
||||
if isinstance(view, Profile):
|
||||
return self._config("authors_profiles_pagination", default)
|
||||
else:
|
||||
return default
|
||||
|
||||
# Retrieve configuration value for pagination per page
|
||||
def _config_pagination_per_page(self, view: View):
|
||||
default = self.config.pagination_per_page
|
||||
if isinstance(view, Archive):
|
||||
return self._config("archive_pagination_per_page", default)
|
||||
if isinstance(view, Category):
|
||||
return self._config("categories_pagination_per_page", default)
|
||||
if isinstance(view, Profile):
|
||||
return self._config("authors_profiles_pagination_per_page", default)
|
||||
else:
|
||||
return default
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Format path for post
|
||||
def _format_path_for_post(self, post: Post, config: MkDocsConfig):
|
||||
categories = post.config.categories[:self.config.post_url_max_categories]
|
||||
@@ -845,6 +930,17 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
path = posixpath.normpath(path.strip("/"))
|
||||
return posixpath.join(self.config.blog_dir, f"{path}.md")
|
||||
|
||||
# Format path for profile
|
||||
def _format_path_for_profile(self, id: str, author: Author):
|
||||
path = self.config.authors_profiles_url_format.format(
|
||||
slug = author.slug or id,
|
||||
name = author.name
|
||||
)
|
||||
|
||||
# Normalize path and strip slashes at the beginning and end
|
||||
path = posixpath.normpath(path.strip("/"))
|
||||
return posixpath.join(self.config.blog_dir, f"{path}.md")
|
||||
|
||||
# Format path for pagination
|
||||
def _format_path_for_pagination(self, view: View, page: int):
|
||||
path = self.config.pagination_url_format.format(
|
||||
|
||||
@@ -273,6 +273,12 @@ class Category(View):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Profile view
|
||||
class Profile(View):
|
||||
pass
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Reference
|
||||
class Reference(Link):
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class PostConfig(Config):
|
||||
categories = UniqueListOfItems(Type(str), default = [])
|
||||
date = PostDate()
|
||||
draft = Optional(Type(bool))
|
||||
pin = Type(bool, default = False)
|
||||
links = Optional(PostLinks())
|
||||
readtime = Optional(Type(int))
|
||||
slug = Optional(Type(str))
|
||||
|
||||
@@ -36,7 +36,7 @@ from io import BytesIO
|
||||
from markdown.extensions.toc import slugify
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from mkdocs.utils import get_yaml_loader
|
||||
from mkdocs.utils.yaml import get_yaml_loader
|
||||
from zipfile import ZipFile, ZIP_DEFLATED
|
||||
|
||||
from .config import InfoConfig
|
||||
@@ -180,7 +180,7 @@ class InfoPlugin(BasePlugin[InfoConfig]):
|
||||
|
||||
# Report the invalid paths to the user
|
||||
if paths_to_validate:
|
||||
log.error(f"One or more paths aren't children of root")
|
||||
log.error("One or more paths aren't children of root")
|
||||
self._help_on_not_in_cwd(paths_to_validate)
|
||||
|
||||
# Create in-memory archive and prompt author for a short descriptive
|
||||
|
||||
19
material/plugins/optimize/__init__.py
Normal file
19
material/plugins/optimize/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
52
material/plugins/optimize/config.py
Normal file
52
material/plugins/optimize/config.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
import os
|
||||
|
||||
from mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import ListOfItems, Type
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Optimize plugin configuration
|
||||
class OptimizeConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
concurrency = Type(int, default = max(1, os.cpu_count() - 1))
|
||||
|
||||
# Settings for caching
|
||||
cache = Type(bool, default = True)
|
||||
cache_dir = Type(str, default = ".cache/plugin/optimize")
|
||||
|
||||
# Settings for optimization
|
||||
optimize = Type(bool, default = True)
|
||||
optimize_png = Type(bool, default = True)
|
||||
optimize_png_speed = Type(int, default = 3)
|
||||
optimize_png_strip = Type(bool, default = True)
|
||||
optimize_jpg = Type(bool, default = True)
|
||||
optimize_jpg_quality = Type(int, default = 60)
|
||||
optimize_jpg_progressive = Type(bool, default = True)
|
||||
optimize_include = ListOfItems(Type(str), default = [])
|
||||
optimize_exclude = ListOfItems(Type(str), default = [])
|
||||
|
||||
# Settings for reporting
|
||||
print_gain = Type(bool, default = True)
|
||||
print_gain_summary = Type(bool, default = True)
|
||||
388
material/plugins/optimize/plugin.py
Normal file
388
material/plugins/optimize/plugin.py
Normal file
@@ -0,0 +1,388 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from fnmatch import fnmatch
|
||||
from colorama import Fore, Style
|
||||
from concurrent.futures import Future
|
||||
from concurrent.futures.thread import ThreadPoolExecutor
|
||||
from hashlib import sha1
|
||||
from mkdocs import utils
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.plugins import BasePlugin
|
||||
from mkdocs.structure.files import File
|
||||
from shutil import which
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from .config import OptimizeConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Optimize plugin
|
||||
class OptimizePlugin(BasePlugin[OptimizeConfig]):
|
||||
supports_multiple_instances = True
|
||||
|
||||
# Manifest
|
||||
manifest: dict[str, str] = {}
|
||||
|
||||
# Initialize plugin
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize incremental builds
|
||||
self.is_serve = False
|
||||
|
||||
# Determine whether we're serving the site
|
||||
def on_startup(self, *, command, dirty):
|
||||
self.is_serve = command == "serve"
|
||||
|
||||
# Initialize thread pool
|
||||
self.pool = ThreadPoolExecutor(self.config.concurrency)
|
||||
self.pool_jobs: dict[str, Future] = {}
|
||||
|
||||
# Resolve and load manifest
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Resolve cache directory (once) - this is necessary, so the cache is
|
||||
# always relative to the configuration file, and thus project, and not
|
||||
# relative to the current working directory, or it would not work with
|
||||
# the projects plugin.
|
||||
path = os.path.abspath(self.config.cache_dir)
|
||||
if path != self.config.cache_dir:
|
||||
self.config.cache_dir = os.path.join(
|
||||
os.path.dirname(config.config_file_path),
|
||||
os.path.normpath(self.config.cache_dir)
|
||||
)
|
||||
|
||||
# Ensure cache directory exists
|
||||
os.makedirs(self.config.cache_dir, exist_ok = True)
|
||||
|
||||
# Initialize manifest
|
||||
self.manifest_file = os.path.join(
|
||||
self.config.cache_dir, "manifest.json"
|
||||
)
|
||||
|
||||
# Load manifest if it exists and the cache should be used
|
||||
if os.path.isfile(self.manifest_file) and self.config.cache:
|
||||
try:
|
||||
with open(self.manifest_file) as f:
|
||||
self.manifest = json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Initialize optimization pipeline
|
||||
def on_env(self, env, *, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if media files should not be optimized
|
||||
if not self.config.optimize:
|
||||
return
|
||||
|
||||
# Filter all optimizable media files and steal reponsibility from MkDocs
|
||||
# by removing them from the files collection. Then, start a concurrent
|
||||
# job that checks if an image was already optimized and can be returned
|
||||
# from the cache, or optimize it accordingly.
|
||||
for file in files.media_files():
|
||||
if self._is_excluded(file):
|
||||
continue
|
||||
|
||||
# Spawn concurrent job to optimize the given image and add future
|
||||
# to job dictionary, as it returns the file we need to copy later
|
||||
path = os.path.join(self.config.cache_dir, file.src_path)
|
||||
self.pool_jobs[file.abs_src_path] = self.pool.submit(
|
||||
self._optimize_image, file, path, config
|
||||
)
|
||||
|
||||
# Steal responsibility from MkDocs
|
||||
files.remove(file)
|
||||
|
||||
# Finish optimization pipeline
|
||||
def on_post_build(self, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if media files should not be optimized
|
||||
if not self.config.optimize:
|
||||
return
|
||||
|
||||
# Reconcile concurrent jobs - we need to wait for all jobs to finish
|
||||
# before we can copy the optimized files to the output directory. If an
|
||||
# exception occurred in one of the jobs, we raise it here, so the build
|
||||
# fails and the author can fix the issue.
|
||||
for path, future in self.pool_jobs.items():
|
||||
if future.exception():
|
||||
raise future.exception()
|
||||
else:
|
||||
file: File = future.result()
|
||||
file.copy_file()
|
||||
|
||||
# Save manifest if cache should be used
|
||||
if self.config.cache:
|
||||
with open(self.manifest_file, "w") as f:
|
||||
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
|
||||
|
||||
# Compute and print gains through optimization
|
||||
if self.config.print_gain_summary:
|
||||
print(Style.NORMAL)
|
||||
print(f" Optimizations:")
|
||||
|
||||
# Print summary for file extension
|
||||
for seek in [".png", ".jpg"]:
|
||||
size = size_opt = 0
|
||||
for path, future in self.pool_jobs.items():
|
||||
file: File = future.result()
|
||||
|
||||
# Skip files that are not of the given type
|
||||
_, extension = os.path.splitext(path)
|
||||
extension = ".jpg" if extension == ".jpeg" else extension
|
||||
if extension != seek:
|
||||
continue
|
||||
|
||||
# Compute size before and after optimization
|
||||
size += os.path.getsize(path)
|
||||
size_opt += os.path.getsize(file.abs_dest_path)
|
||||
|
||||
# Compute absolute and relative gain
|
||||
if size and size_opt:
|
||||
gain_abs = size - size_opt
|
||||
gain_rel = (1 - size_opt / size) * 100
|
||||
|
||||
# Print summary for files
|
||||
print(
|
||||
f" *{seek} {Fore.GREEN}{_size(size_opt)}"
|
||||
f"{Fore.WHITE}{Style.DIM} ↓ "
|
||||
f"{_size(gain_abs)} [{gain_rel:3.1f}%]"
|
||||
f"{Style.RESET_ALL}"
|
||||
)
|
||||
|
||||
# Reset all styles
|
||||
print(Style.RESET_ALL)
|
||||
|
||||
# Save manifest on shutdown
|
||||
def on_shutdown(self):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Shutdown thread pool - if we're on Python 3.9 and above, cancel all
|
||||
# pending futures that have not yet been scheduled
|
||||
if sys.version_info >= (3, 9):
|
||||
self.pool.shutdown(cancel_futures = True)
|
||||
else:
|
||||
self.pool.shutdown()
|
||||
|
||||
# Save manifest if cache should be used
|
||||
if self.manifest and self.config.cache:
|
||||
with open(self.manifest_file, "w") as f:
|
||||
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Check if a file can be optimized
|
||||
def _is_optimizable(self, file: File):
|
||||
|
||||
# Check if PNG images should be optimized
|
||||
if file.url.endswith((".png")):
|
||||
return self.config.optimize_png
|
||||
|
||||
# Check if JPG images should be optimized
|
||||
if file.url.endswith((".jpg", ".jpeg")):
|
||||
return self.config.optimize_jpg
|
||||
|
||||
# File can not be optimized by the plugin
|
||||
return False
|
||||
|
||||
# Check if the given file is excluded
|
||||
def _is_excluded(self, file: File):
|
||||
if not self._is_optimizable(file):
|
||||
return True
|
||||
|
||||
# Check if file matches one of the inclusion patterns
|
||||
path = file.src_path
|
||||
if self.config.optimize_include:
|
||||
for pattern in self.config.optimize_include:
|
||||
if fnmatch(file.src_uri, pattern):
|
||||
return False
|
||||
|
||||
# File is not included
|
||||
log.debug(f"Excluding file '{path}' due to inclusion patterns")
|
||||
return True
|
||||
|
||||
# Check if file matches one of the exclusion patterns
|
||||
for pattern in self.config.optimize_exclude:
|
||||
if fnmatch(file.src_uri, pattern):
|
||||
log.debug(f"Excluding file '{path}' due to exclusion patterns")
|
||||
return True
|
||||
|
||||
# File is not excluded
|
||||
return False
|
||||
|
||||
# Optimize image and write to cache
|
||||
def _optimize_image(self, file: File, path: str, config: MkDocsConfig):
|
||||
with open(file.abs_src_path, "rb") as f:
|
||||
data = f.read()
|
||||
hash = sha1(data).hexdigest()
|
||||
|
||||
# Check if file hash changed, so we need to optimize again
|
||||
prev = self.manifest.get(file.url, "")
|
||||
if hash != prev or not os.path.isfile(path):
|
||||
os.makedirs(os.path.dirname(path), exist_ok = True)
|
||||
|
||||
# Optimize PNG image using pngquant
|
||||
if file.url.endswith((".png")):
|
||||
self._optimize_image_png(file, path, config)
|
||||
|
||||
# Optimize JPG image using pillow
|
||||
if file.url.endswith((".jpg", ".jpeg")):
|
||||
self._optimize_image_jpg(file, path, config)
|
||||
|
||||
# Compute size before and after optimization
|
||||
size = len(data)
|
||||
size_opt = os.path.getsize(path)
|
||||
|
||||
# Compute absolute and relative gain
|
||||
gain_abs = size - size_opt
|
||||
gain_rel = (1 - size_opt / size) * 100
|
||||
|
||||
# Print how much we gained, if we did and desired
|
||||
gain = ""
|
||||
if gain_abs and self.config.print_gain:
|
||||
gain += " ↓ "
|
||||
gain += " ".join([_size(gain_abs), f"[{gain_rel:3.1f}%]"])
|
||||
|
||||
# Print summary for file
|
||||
log.info(
|
||||
f"Optimized media file: {file.src_uri} "
|
||||
f"{Fore.GREEN}{_size(size_opt)}"
|
||||
f"{Fore.WHITE}{Style.DIM}{gain}"
|
||||
f"{Style.RESET_ALL}"
|
||||
)
|
||||
|
||||
# Update manifest by associating file with hash
|
||||
self.manifest[file.url] = hash
|
||||
|
||||
# Compute project root
|
||||
root = os.path.dirname(config.config_file_path)
|
||||
|
||||
# Compute source file system path
|
||||
file.abs_src_path = path
|
||||
file.src_path = os.path.relpath(path, root)
|
||||
|
||||
# Return file to be copied from cache
|
||||
return file
|
||||
|
||||
# Optimize PNG image - we first tried to use libimagequant, but encountered
|
||||
# the occassional segmentation fault, which means it's probably not a good
|
||||
# choice. Instead, we just rely on pngquant which seems much more stable.
|
||||
def _optimize_image_png(self, file: File, path: str, config: MkDocsConfig):
|
||||
|
||||
# Check if the required dependencies for optimizing are available, which
|
||||
# is, at the absolute minimum, the 'pngquant' binary, and raise an error
|
||||
# to the caller, so he can decide what to do with the error. The caller
|
||||
# can treat this as a warning or an error to abort the build.
|
||||
if not which("pngquant"):
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
path = os.path.relpath(file.abs_src_path, docs)
|
||||
raise PluginError(
|
||||
f"Couldn't optimize image '{path}' in '{docs}': 'pngquant' "
|
||||
f"not found. Make sure 'pngquant' is installed and in your path"
|
||||
)
|
||||
|
||||
# Build command line arguments
|
||||
args = ["pngquant",
|
||||
"--force", "--skip-if-larger",
|
||||
"--output", path,
|
||||
"--speed", f"{self.config.optimize_png_speed}"
|
||||
]
|
||||
|
||||
# Add flag to remove optional metadata
|
||||
if self.config.optimize_png_strip:
|
||||
args.append("--strip")
|
||||
|
||||
# Set input file and run, then check if pngquant actually wrote a file,
|
||||
# as we instruct it not to if the size of the optimized file is larger.
|
||||
# This can happen if files are already compressed and optimized by
|
||||
# the author. In that case, just copy the original file.
|
||||
subprocess.run([*args, file.abs_src_path])
|
||||
if not os.path.isfile(path):
|
||||
utils.copy_file(file.abs_src_path, path)
|
||||
|
||||
# Optimize JPG image
|
||||
def _optimize_image_jpg(self, file: File, path: str, config: MkDocsConfig):
|
||||
|
||||
# Check if the required dependencies for optimizing are available, which
|
||||
# is, at the absolute minimum, the 'pillow' package, and raise an error
|
||||
# to the caller, so he can decide what to do with the error. The caller
|
||||
# can treat this as a warning or an error to abort the build.
|
||||
if not _supports("Image"):
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
path = os.path.relpath(file.abs_src_path, docs)
|
||||
raise PluginError(
|
||||
f"Couldn't optimize image '{path}' in '{docs}': install "
|
||||
f"required dependencies – pip install 'mkdocs-material[imaging]'"
|
||||
)
|
||||
|
||||
# Open and save optimized image
|
||||
image = Image.open(file.abs_src_path)
|
||||
image.save(path, "jpeg",
|
||||
quality = self.config.optimize_jpg_quality,
|
||||
progressive = self.config.optimize_jpg_progressive
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Check for presence of optional imports
|
||||
@functools.lru_cache(maxsize = None)
|
||||
def _supports(name: str):
|
||||
return name in globals()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Print human-readable size
|
||||
def _size(value):
|
||||
for unit in ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB"]:
|
||||
if abs(value) < 1000.0:
|
||||
return f"{value:3.1f} {unit}"
|
||||
value /= 1000.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.optimize")
|
||||
@@ -21,7 +21,21 @@
|
||||
import os
|
||||
|
||||
from mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import DictOfItems, Type
|
||||
from mkdocs.config.config_options import (
|
||||
Choice, Deprecated, DictOfItems, ListOfItems, Type
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Options
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Options for log level
|
||||
LogLevel = (
|
||||
"error",
|
||||
"warn",
|
||||
"info",
|
||||
"debug"
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
@@ -36,8 +50,29 @@ class PrivacyConfig(Config):
|
||||
cache = Type(bool, default = True)
|
||||
cache_dir = Type(str, default = ".cache/plugin/privacy")
|
||||
|
||||
# Settings for logging
|
||||
log = Type(bool, default = True)
|
||||
log_level = Choice(LogLevel, default = "info")
|
||||
|
||||
# Settings for external assets
|
||||
assets = Type(bool, default = True)
|
||||
assets_fetch = Type(bool, default = True)
|
||||
assets_fetch_dir = Type(str, default = "assets/external")
|
||||
assets_include = ListOfItems(Type(str), default = [])
|
||||
assets_exclude = ListOfItems(Type(str), default = [])
|
||||
assets_expr_map = DictOfItems(Type(str), default = {})
|
||||
|
||||
# Settings for external links
|
||||
links = Type(bool, default = True)
|
||||
links_attr_map = DictOfItems(Type(str), default = {})
|
||||
links_noopener = Type(bool, default = True)
|
||||
|
||||
# Deprecated settings
|
||||
external_assets = Deprecated(message = "Deprecated, use 'assets_fetch'")
|
||||
external_assets_dir = Deprecated(moved_to = "assets_fetch_dir")
|
||||
external_assets_include = Deprecated(moved_to = "assets_include")
|
||||
external_assets_exclude = Deprecated(moved_to = "assets_exclude")
|
||||
external_assets_expr = Deprecated(moved_to = "assets_expr_map")
|
||||
external_links = Deprecated(moved_to = "links")
|
||||
external_links_attr_map = Deprecated(moved_to = "links_attr_map")
|
||||
external_links_noopener = Deprecated(moved_to = "links_noopener")
|
||||
|
||||
@@ -29,7 +29,9 @@ import requests
|
||||
import sys
|
||||
|
||||
from colorama import Fore, Style
|
||||
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
||||
from concurrent.futures import Future, wait
|
||||
from concurrent.futures.thread import ThreadPoolExecutor
|
||||
from fnmatch import fnmatch
|
||||
from hashlib import sha1
|
||||
from mkdocs.config.config_options import ExtraScriptValue
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
@@ -52,6 +54,7 @@ DEFAULT_TIMEOUT_IN_SECS = 5
|
||||
|
||||
# Privacy plugin
|
||||
class PrivacyPlugin(BasePlugin[PrivacyConfig]):
|
||||
supports_multiple_instances = True
|
||||
|
||||
# Initialize thread pools and asset collections
|
||||
def on_config(self, config):
|
||||
@@ -65,12 +68,20 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
|
||||
|
||||
# Initialize collections of external assets
|
||||
self.assets = Files([])
|
||||
self.assets_done: list[File] = []
|
||||
self.assets_expr_map = {
|
||||
".css": r"url\(\s*([\"']?)(?P<url>http?[^)'\"]+)\1\s*\)",
|
||||
".js": r"[\"'](?P<url>http[^\"']+\.(?:css|js(?:on)?))[\"']",
|
||||
**self.config.assets_expr_map
|
||||
}
|
||||
|
||||
# Set log level or disable logging altogether - @todo when refactoring
|
||||
# this plugin for the next time, we should put this into a factory
|
||||
if not self.config.log:
|
||||
log.disabled = True
|
||||
else:
|
||||
log.setLevel(self.config.log_level.upper())
|
||||
|
||||
# Process external style sheets and scripts (run latest) - run this after
|
||||
# all other plugins, so they can add additional assets
|
||||
@event_priority(-100)
|
||||
@@ -127,7 +138,13 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
|
||||
# Process external images in page (run latest) - this stage is the earliest
|
||||
# we can start processing external images, since images are the most common
|
||||
# type of external asset when writing. Thus, we create and enqueue a job for
|
||||
# each image we find that checks if the image needs to be downloaded.
|
||||
# each image we find that checks if the image needs to be downloaded. Also,
|
||||
# downloading all external images at this stage, we reconcile all concurrent
|
||||
# jobs in `on_env`, which is the stage in which the optimize plugin will
|
||||
# evaluate what images can and need to be optimized. This means we can pass
|
||||
# external images through the optimization pipeline. Additionally, we run
|
||||
# this after all other plugins, so we allow them to add additional images
|
||||
# to the content of the page. How cool is that?
|
||||
@event_priority(-100)
|
||||
def on_page_content(self, html, *, page, config, files):
|
||||
if not self.config.enabled:
|
||||
@@ -149,13 +166,27 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
|
||||
if not self._is_excluded(url, page.file):
|
||||
self._queue(url, config, concurrent = True)
|
||||
|
||||
# Sync all concurrent jobs
|
||||
# Reconcile jobs and pass external assets to MkDocs (run earlier) - allow
|
||||
# other plugins (e.g. optimize plugin) to post-process external assets
|
||||
@event_priority(50)
|
||||
def on_env(self, env, *, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Wait until all jobs until now are finished
|
||||
# Reconcile concurrent jobs and clear thread pool, as we will reuse the
|
||||
# same thread pool for fetching all remaining external assets
|
||||
wait(self.pool_jobs)
|
||||
self.pool_jobs.clear()
|
||||
|
||||
# Append all downloaded assets that are not style sheets or scripts to
|
||||
# MkDocs's collection of files, making them available to other plugins
|
||||
# for further processing. The remaining exteral assets are patched
|
||||
# before copying, which is done at the end of the build process.
|
||||
for file in self.assets:
|
||||
_, extension = posixpath.splitext(file.dest_uri)
|
||||
if extension not in [".css", ".js"]:
|
||||
self.assets_done.append(file)
|
||||
files.append(file)
|
||||
|
||||
# Process external assets in template (run later)
|
||||
@event_priority(-50)
|
||||
@@ -180,7 +211,8 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
|
||||
# Parse and replace links to external assets
|
||||
return self._parse_html(output, page.file, config)
|
||||
|
||||
# Reconcile jobs (run earlier)
|
||||
# Reconcile jobs (run earlier) - allow other plugins (e.g. optimize plugin)
|
||||
# to process all downloaded assets, which is why we must reconcile here
|
||||
@event_priority(50)
|
||||
def on_post_build(self, *, config):
|
||||
if not self.config.enabled:
|
||||
@@ -200,10 +232,10 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
|
||||
self._patch, file
|
||||
))
|
||||
|
||||
# Otherwise just copy external asset to output directory if it
|
||||
# exists, i.e., if the download succeeded
|
||||
else:
|
||||
if os.path.exists(file.abs_src_path):
|
||||
# Otherwise just copy external asset to output directory, if we
|
||||
# haven't handed control to MkDocs in `on_env` before
|
||||
elif file not in self.assets_done:
|
||||
if os.path.exists(str(file.abs_src_path)):
|
||||
file.copy_file()
|
||||
|
||||
# Reconcile concurrent jobs for the last time, so the plugins following
|
||||
@@ -236,6 +268,28 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
|
||||
Style.RESET_ALL
|
||||
])
|
||||
|
||||
# Check if URL matches one of the inclusion patterns
|
||||
if self.config.assets_include:
|
||||
for pattern in self.config.assets_include:
|
||||
if fnmatch(self._path_from_url(url), pattern):
|
||||
return False
|
||||
|
||||
# File is not included
|
||||
log.debug(
|
||||
f"Excluding external file '{url.geturl()}' {via}due to "
|
||||
f"inclusion patterns"
|
||||
)
|
||||
return True
|
||||
|
||||
# Check if URL matches one of the exclusion patterns
|
||||
for pattern in self.config.assets_exclude:
|
||||
if fnmatch(self._path_from_url(url), pattern):
|
||||
log.debug(
|
||||
f"Excluding external file '{url.geturl()}' {via}due to "
|
||||
f"exclusion patterns"
|
||||
)
|
||||
return True
|
||||
|
||||
# Print warning if fetching is not enabled
|
||||
if not self.config.assets_fetch:
|
||||
log.warning(f"External file: {url.geturl()} {via}")
|
||||
@@ -301,6 +355,21 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
|
||||
def replace(match: Match):
|
||||
el = self._parse_fragment(match.group())
|
||||
|
||||
# Handle external link
|
||||
if self.config.links and el.tag == "a":
|
||||
for key, value in self.config.links_attr_map.items():
|
||||
el.set(key, value)
|
||||
|
||||
# Set `rel=noopener` if link opens in a new window
|
||||
if self.config.links_noopener:
|
||||
if el.get("target") == "_blank":
|
||||
rel = re.findall(r"\S+", el.get("rel", ""))
|
||||
if "noopener" not in rel:
|
||||
rel.append("noopener")
|
||||
|
||||
# Set relationships after adding `noopener`
|
||||
el.set("rel", " ".join(rel))
|
||||
|
||||
# Handle external style sheet or preconnect hint
|
||||
if el.tag == "link":
|
||||
url = urlparse(el.get("href"))
|
||||
|
||||
19
material/plugins/projects/__init__.py
Normal file
19
material/plugins/projects/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
320
material/plugins/projects/builder/__init__.py
Normal file
320
material/plugins/projects/builder/__init__.py
Normal file
@@ -0,0 +1,320 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import Future, as_completed
|
||||
from concurrent.futures.process import ProcessPoolExecutor
|
||||
from logging import Logger
|
||||
from material.plugins.projects.config import ProjectsConfig
|
||||
from material.plugins.projects.structure import Project, ProjectJob
|
||||
from mkdocs.commands.build import build
|
||||
from mkdocs.config.base import ConfigErrors, ConfigWarnings
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import Abort
|
||||
from mkdocs.livereload import LiveReloadServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .log import (
|
||||
get_log_for,
|
||||
get_log_formatter,
|
||||
get_log_handler,
|
||||
get_log_level_for
|
||||
)
|
||||
from .watcher import ProjectsWatcher
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Projects builder
|
||||
class ProjectsBuilder:
|
||||
|
||||
# Set of projects
|
||||
projects: set[Project] = set()
|
||||
|
||||
# Projects watcher
|
||||
watcher: ProjectsWatcher | None = None
|
||||
|
||||
# Initialize projects builder - compared to our other concurrent plugins,
|
||||
# this plugin is forced to use a process pool in order to guarantee proper
|
||||
# isolation, as MkDocs itself is not thread-safe. We also need to recreate
|
||||
# the process pool on every build, or CTRL-C is broken
|
||||
def __init__(self, config: MkDocsConfig, plugin: ProjectsConfig):
|
||||
|
||||
# Initialize root project
|
||||
self.root = Project(config.config_file_path, plugin)
|
||||
self.root.config = config
|
||||
|
||||
# Initialize process pool
|
||||
self.pool = ProcessPoolExecutor
|
||||
self.pool_jobs: dict[str, Future] = {}
|
||||
|
||||
# Build projects
|
||||
def build(self, serve: bool = False, dirty: bool = False):
|
||||
self.pool = ProcessPoolExecutor(
|
||||
self.root.plugin.concurrency,
|
||||
mp_context = multiprocessing.get_context("spawn")
|
||||
)
|
||||
|
||||
# Determine projects in topological order and prepare for building
|
||||
built: list[str] = []
|
||||
queue = [*self.root.jobs()]
|
||||
for job in queue:
|
||||
_setup(job.project, self.root, serve)
|
||||
if serve:
|
||||
self._link(job.project)
|
||||
|
||||
# Schedule projects for building
|
||||
for job in reversed(queue):
|
||||
if self._schedule(job, serve, dirty):
|
||||
queue.remove(job)
|
||||
|
||||
# Build loop - iteratively build more projects while there are still
|
||||
# projects to be built, sticking to the topological order.
|
||||
while len(built) < len(self.pool_jobs):
|
||||
for future in as_completed(self.pool_jobs.values()):
|
||||
slug, errors, warnings = future.result()
|
||||
if slug in built:
|
||||
continue
|
||||
|
||||
# Mark project as built
|
||||
built.append(slug)
|
||||
|
||||
# Schedule projects for building
|
||||
for job in reversed(queue):
|
||||
if self._schedule(job, serve, dirty):
|
||||
queue.remove(job)
|
||||
|
||||
# Print errors and warnings
|
||||
for project in self.projects:
|
||||
if project.slug == slug:
|
||||
_print(get_log_for(project), errors, warnings)
|
||||
break
|
||||
|
||||
# Shutdown process pool
|
||||
self.pool.shutdown()
|
||||
if self.watcher:
|
||||
|
||||
# Update watched paths
|
||||
for project in self.projects:
|
||||
if project.slug not in built:
|
||||
self.pool_jobs.pop(project.slug, None)
|
||||
self.watcher.unwatch(project)
|
||||
else:
|
||||
self.watcher.watch(project, self.taint)
|
||||
|
||||
# Taint a project to schedule it for building
|
||||
def taint(self, project: Project):
|
||||
self.pool_jobs.pop(project.slug, None)
|
||||
|
||||
# Watch and serve projects
|
||||
def serve(self, server: LiveReloadServer, is_dirty: bool = False):
|
||||
self.watcher = ProjectsWatcher(server)
|
||||
self.watcher.watch(self.root, self.taint)
|
||||
for project in self.projects:
|
||||
self.watcher.watch(project, self.taint)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Create symlink for project if we're serving the site
|
||||
def _link(self, project: Project):
|
||||
|
||||
# Compute path for slug from current project - normalize path,
|
||||
# as paths computed from slugs or site URLs use forward slashes
|
||||
path = project.path(self.root)
|
||||
path = os.path.normpath(path)
|
||||
|
||||
# Create symbolic link, if we haven't already
|
||||
path = os.path.join(self.root.config.site_dir, path)
|
||||
if not os.path.islink(path):
|
||||
|
||||
# Ensure link target exists
|
||||
target = os.path.realpath(os.path.dirname(path))
|
||||
if not os.path.exists(target):
|
||||
os.makedirs(target, exist_ok = True)
|
||||
|
||||
# Create symbolic link
|
||||
os.makedirs(os.path.dirname(path), exist_ok = True)
|
||||
os.symlink(project.config.site_dir, path)
|
||||
|
||||
# Schedule project for building - spawn concurrent job to build the project
|
||||
# and add a future to the jobs dictionary to link build results to projects
|
||||
def _schedule(self, job: ProjectJob, serve: bool, dirty: bool):
|
||||
self.projects.add(job.project)
|
||||
|
||||
# Exit early, if project doesn't need to be re built
|
||||
if job.project.slug in self.pool_jobs:
|
||||
return True
|
||||
|
||||
# Check if dependencies have been built already, and if so, remove
|
||||
# them from the list of dependencies. If a dependency has failed to
|
||||
# build, we'll raise an exception, which will be caught by the main
|
||||
# process, and the entire build will be aborted.
|
||||
for dependency in [*job.dependencies]:
|
||||
future = self.pool_jobs[dependency.slug]
|
||||
if future.running():
|
||||
continue
|
||||
|
||||
# If the dependency has failed to build, we'll raise an exception
|
||||
# to abort the entire build, as we can't build the project itself
|
||||
# without the dependency. This will be caught by the main process.
|
||||
# Otherwise, we'll remove the dependency from the list.
|
||||
if future.exception():
|
||||
raise future.exception()
|
||||
elif future.done():
|
||||
job.dependencies.remove(dependency)
|
||||
|
||||
# If all dependencies of the project have been built, we can build
|
||||
# the project itself by spawning a concurrent job
|
||||
if not job.dependencies:
|
||||
self.pool_jobs[job.project.slug] = self.pool.submit(
|
||||
_build, job.project, serve, dirty,
|
||||
get_log_level_for(job.project)
|
||||
)
|
||||
|
||||
# Return whether the project has been scheduled
|
||||
return not job.dependencies
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Setup project by preparing it for building
|
||||
def _setup(project: Project, root: Project, serve: bool):
|
||||
assert project.slug != "."
|
||||
|
||||
# Retrieve configuration of top-level project and transform project
|
||||
transform = root.plugin.projects_config_transform
|
||||
if isinstance(transform, Callable):
|
||||
transform(project, root)
|
||||
|
||||
# If the top-level project defines a site URL, we need to make sure that the
|
||||
# site URL of the project is set as well, setting it to the path we derive
|
||||
# from the slug. This allows to define the URL independent of the entire
|
||||
# project's directory structure. If the top-level project doesn't define a
|
||||
# site URL, it might be the case that the author is building a consolidated
|
||||
# project of several nested projects that are independent, but which should
|
||||
# be bundled together for distribution. As this is a case that is quite
|
||||
# common, we're not raising a warning or error.
|
||||
path = project.path(root)
|
||||
if root.config.site_url:
|
||||
|
||||
# If the project doesn't have a site URL, compute it from the site URL
|
||||
# of the top-level project and the path derived from the slug
|
||||
if not project.config.site_url:
|
||||
project.config.site_url = posixpath.join(
|
||||
root.config.site_url,
|
||||
path
|
||||
)
|
||||
|
||||
# If we're serving the site, replace the project's host name with the
|
||||
# dev server address, so we can serve nested projects as well
|
||||
if serve:
|
||||
url = urlparse(project.config.site_url)
|
||||
url = url._replace(
|
||||
scheme = "http",
|
||||
netloc = str(root.config.dev_addr)
|
||||
)
|
||||
|
||||
# Update site URL with dev server address
|
||||
project.config.site_url = url.geturl()
|
||||
|
||||
# If we're building the site, the project's output must be written to the
|
||||
# site directory of the top-level project, so we can serve it from there
|
||||
if not serve:
|
||||
project.config.site_dir = os.path.join(
|
||||
root.config.site_dir,
|
||||
os.path.normpath(path)
|
||||
)
|
||||
|
||||
# If we're serving the site, we must fall back to symbolic links, as MkDocs
|
||||
# will empty the entire site directory every time it performs a build
|
||||
else:
|
||||
project.config.site_dir = os.path.join(
|
||||
os.path.dirname(project.config.config_file_path),
|
||||
project.config.site_dir
|
||||
)
|
||||
|
||||
# Build project - note that regardless of whether MkDocs was started in build
|
||||
# or serve mode, projects must always be built, as they're served by the root
|
||||
def _build(project: Project, serve: bool, dirty: bool, level = logging.WARN):
|
||||
config = project.config
|
||||
|
||||
# Change working directory to project root - this is necessary, or relative
|
||||
# paths used in extensions and plugins will be resolved incorrectly
|
||||
os.chdir(os.path.dirname(config.config_file_path))
|
||||
|
||||
# Validate configuration
|
||||
errors, warnings = config.validate()
|
||||
if not errors:
|
||||
|
||||
# Retrieve and configure MkDocs' logger
|
||||
log = logging.getLogger("mkdocs")
|
||||
log.setLevel(level)
|
||||
|
||||
# Hack: there seems to be an inconsistency between operating systems,
|
||||
# and it's yet unclear where this is coming from - on macOS, the MkDocs
|
||||
# default logger has no designated handler registered, but on Linux it
|
||||
# does. If there's no handler, we need to create one. If there is, we
|
||||
# must only set the formatter, as otherwise we'll end up with the same
|
||||
# message printed on two log handlers - see https://t.ly/q7UEq
|
||||
handler = get_log_handler(project)
|
||||
if not log.hasHandlers():
|
||||
log.addHandler(handler)
|
||||
else:
|
||||
for handler in log.handlers:
|
||||
handler.setFormatter(get_log_formatter(project))
|
||||
|
||||
# Build project and dispatch startup and shutdown plugin events - note
|
||||
# that we must pass the correct command to the event handler, but run
|
||||
# the build command anyway, because otherwise some plugins will not
|
||||
# run in serve mode.
|
||||
command = "serve" if serve else "build"
|
||||
config.plugins.run_event("startup", command = command, dirty = dirty)
|
||||
try:
|
||||
build(config, dirty = dirty)
|
||||
finally:
|
||||
config.plugins.run_event("shutdown")
|
||||
log.removeHandler(handler)
|
||||
|
||||
# Return slug, errors and warnings
|
||||
return project.slug, errors, warnings
|
||||
|
||||
# Print errors and warnings resulting from building a project
|
||||
def _print(log: Logger, errors: ConfigErrors, warnings: ConfigWarnings):
|
||||
|
||||
# Print warnings
|
||||
for value, message in warnings:
|
||||
log.warning(f"Config value '{value}': {message}")
|
||||
|
||||
# Print errors
|
||||
for value, message in errors:
|
||||
log.error(f"Config value '{value}': {message}")
|
||||
|
||||
# Abort if there were errors
|
||||
if errors:
|
||||
raise Abort(f"Aborted with {len(errors)} configuration errors")
|
||||
100
material/plugins/projects/builder/log.py
Normal file
100
material/plugins/projects/builder/log.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from click import style
|
||||
from logging import Filter
|
||||
from material.plugins.projects.structure import Project
|
||||
from mkdocs.__main__ import ColorFormatter
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Dirty build warning filter
|
||||
class ProjectsFilter(Filter):
|
||||
|
||||
# Filter log messages
|
||||
def filter(self, record):
|
||||
message = record.getMessage()
|
||||
return not message.startswith("A 'dirty' build")
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Retrieve logger for project
|
||||
def get_log_for(project: Project):
|
||||
log = logging.getLogger("".join(["mkdocs.material.projects", project.slug]))
|
||||
|
||||
# Ensure logger does not propagate messags to parent logger, or messages
|
||||
# will be printed multiple times, and attach handler with color formatter
|
||||
log.propagate = False
|
||||
if not log.hasHandlers():
|
||||
log.addHandler(get_log_handler(project))
|
||||
log.setLevel(get_log_level_for(project))
|
||||
|
||||
# Return logger
|
||||
return log
|
||||
|
||||
# Retrieve log level for project
|
||||
def get_log_level_for(project: Project):
|
||||
level = logging.INFO
|
||||
|
||||
# Determine log level as set in MkDocs - if the build is started with the
|
||||
# `--quiet` flag, the log level is set to `ERROR` to suppress all messages,
|
||||
# except for errors. If it's started with `--verbose`, MkDocs sets the log
|
||||
# level to `DEBUG`, the most verbose of all log levels.
|
||||
log = logging.getLogger("mkdocs")
|
||||
for handler in log.handlers:
|
||||
level = handler.level
|
||||
break
|
||||
|
||||
# Determine if MkDocs was invoked with the `--quiet` flag and the log level
|
||||
# as configured in the plugin configuration. When `--quiet` is set, or the
|
||||
# projects plugin configuration disables logging, ignore the configured log
|
||||
# level and set it to `ERROR` to suppress all messages.
|
||||
quiet = level == logging.ERROR
|
||||
level = project.plugin.log_level.upper()
|
||||
if quiet or not project.plugin.log:
|
||||
level = logging.ERROR
|
||||
|
||||
# Retun log level
|
||||
return level
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Retrieve log handler for project
|
||||
def get_log_handler(project: Project):
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(get_log_formatter(project))
|
||||
|
||||
# Add filter to suppress dirty build warning, or we'll get as many of those
|
||||
# as projects are built - one warning is surely enough, KTHXBYE
|
||||
handler.addFilter(ProjectsFilter())
|
||||
return handler
|
||||
|
||||
# Retrieve log formatter for project
|
||||
def get_log_formatter(project: Project):
|
||||
prefix = style(f"project://{project.slug}", underline = True)
|
||||
return ColorFormatter(f"[{prefix}] %(message)s")
|
||||
98
material/plugins/projects/builder/watcher/__init__.py
Normal file
98
material/plugins/projects/builder/watcher/__init__.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from collections.abc import Callable
|
||||
from material.plugins.projects.structure import Project
|
||||
from mkdocs.livereload import LiveReloadServer
|
||||
|
||||
from .handler import ProjectChanged, ProjectAddedOrRemoved
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Projects watcher
|
||||
class ProjectsWatcher:
|
||||
|
||||
# Set of watched paths
|
||||
watched: set[str] = set()
|
||||
|
||||
# Initialize projects watcher
|
||||
def __init__(self, server: LiveReloadServer):
|
||||
self.server = server
|
||||
|
||||
# Watch project and invoke function on change
|
||||
def watch(self, project: Project, fn: Callable):
|
||||
self._on_project_changed(project, fn)
|
||||
self._on_project_added_or_removed(project, fn)
|
||||
|
||||
# Stop watching project
|
||||
def unwatch(self, project: Project):
|
||||
|
||||
# Traverse all watched paths
|
||||
root = os.path.dirname(project.config.config_file_path)
|
||||
for path in [*self.watched]:
|
||||
|
||||
# Remove path from watched paths
|
||||
if path.startswith(root):
|
||||
self.server.unwatch(path)
|
||||
self.watched.remove(path)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Register event handler for project changes
|
||||
def _on_project_changed(self, project: Project, fn: Callable):
|
||||
|
||||
# Resolve project root and docs directory
|
||||
root = os.path.dirname(project.config.config_file_path)
|
||||
docs = os.path.join(root, project.config.docs_dir)
|
||||
|
||||
# Collect all paths to watch
|
||||
paths = set([docs, project.config.config_file_path])
|
||||
for path in project.config.watch:
|
||||
paths.add(os.path.join(root, path))
|
||||
|
||||
# Register event handler for unwatched paths
|
||||
handler = ProjectChanged(project, fn)
|
||||
for path in paths - self.watched:
|
||||
self.server.watch(path)
|
||||
|
||||
# Add path and its descendents to watched paths
|
||||
self.server.observer.schedule(handler, path, recursive = True)
|
||||
self.watched.add(path)
|
||||
|
||||
# Register event handler for project additions and removals
|
||||
def _on_project_added_or_removed(self, project: Project, fn: Callable):
|
||||
|
||||
# Resolve project root and path to projects directory
|
||||
root = os.path.dirname(project.config.config_file_path)
|
||||
path = os.path.join(root, project.plugin.projects_dir)
|
||||
|
||||
# Register event handler for unwatched paths
|
||||
handler = ProjectAddedOrRemoved(project, fn)
|
||||
if path not in self.watched and os.path.isdir(path):
|
||||
|
||||
# Add path to watched paths
|
||||
self.server.observer.schedule(handler, path)
|
||||
self.watched.add(path)
|
||||
105
material/plugins/projects/builder/watcher/handler.py
Normal file
105
material/plugins/projects/builder/watcher/handler.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from collections.abc import Callable
|
||||
from material.plugins.projects.structure import Project
|
||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project changed
|
||||
class ProjectChanged(FileSystemEventHandler):
|
||||
|
||||
# Initialize event handler
|
||||
def __init__(self, project: Project, handler: Callable):
|
||||
self.project = project
|
||||
self.handler = handler
|
||||
|
||||
# Handle file event
|
||||
def on_any_event(self, event: FileSystemEvent):
|
||||
self._handle(event)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Invoke file event handler
|
||||
def _handle(self, event: FileSystemEvent):
|
||||
config = self.project.config
|
||||
|
||||
# Resolve path to docs directory
|
||||
base = os.path.dirname(config.config_file_path)
|
||||
docs = os.path.join(base, config.docs_dir)
|
||||
|
||||
# Resolve project root and path to changed file
|
||||
root = os.path.relpath(base)
|
||||
path = os.path.relpath(event.src_path, root)
|
||||
|
||||
# Check if mkdocs.yml or docs directory was deleted
|
||||
if event.src_path in [docs, config.config_file_path]:
|
||||
if event.event_type == "deleted":
|
||||
return
|
||||
|
||||
# Invoke handler and print message that we're scheduling a build
|
||||
log.info(f"Schedule build due to '{path}' in '{root}'")
|
||||
self.handler(self.project)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project added or removed
|
||||
class ProjectAddedOrRemoved(FileSystemEventHandler):
|
||||
|
||||
# Initialize event handler
|
||||
def __init__(self, project: Project, handler: Callable):
|
||||
self.project = project
|
||||
self.handler = handler
|
||||
|
||||
# Handle file creation event
|
||||
def on_created(self, event: FileSystemEvent):
|
||||
self._handle(event)
|
||||
|
||||
# Handle file deletion event
|
||||
def on_deleted(self, event: FileSystemEvent):
|
||||
self._handle(event)
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
# Invoke file event handler
|
||||
def _handle(self, event: FileSystemEvent):
|
||||
config = self.project.config
|
||||
|
||||
# Touch mkdocs.yml to trigger rebuild
|
||||
if os.path.isfile(config.config_file_path):
|
||||
os.utime(config.config_file_path, None)
|
||||
|
||||
# Invoke handler
|
||||
self.handler(self.project)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs")
|
||||
64
material/plugins/projects/config.py
Normal file
64
material/plugins/projects/config.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
import os
|
||||
|
||||
from collections.abc import Callable
|
||||
from mkdocs.config.config_options import Choice, Optional, Type
|
||||
from mkdocs.config.base import Config
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Options
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Options for log level
|
||||
LogLevel = (
|
||||
"error",
|
||||
"warn",
|
||||
"info",
|
||||
"debug"
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Projects plugin configuration
|
||||
class ProjectsConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
concurrency = Type(int, default = max(1, os.cpu_count() - 1))
|
||||
|
||||
# Settings for caching
|
||||
cache = Type(bool, default = True)
|
||||
cache_dir = Type(str, default = ".cache/plugin/projects")
|
||||
|
||||
# Settings for logging
|
||||
log = Type(bool, default = True)
|
||||
log_level = Choice(LogLevel, default = "info")
|
||||
|
||||
# Settings for projects
|
||||
projects = Type(bool, default = True)
|
||||
projects_dir = Type(str, default = "projects")
|
||||
projects_config_files = Type(str, default = "*/mkdocs.yml")
|
||||
projects_config_transform = Optional(Type(Callable))
|
||||
projects_root_dir = Optional(Type(str))
|
||||
|
||||
# Settings for hoisting
|
||||
hoisting = Type(bool, default = True)
|
||||
292
material/plugins/projects/plugin.py
Normal file
292
material/plugins/projects/plugin.py
Normal file
@@ -0,0 +1,292 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
from jinja2 import pass_context
|
||||
from jinja2.runtime import Context
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs import utils
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from mkdocs.structure import StructureItem
|
||||
from mkdocs.structure.files import Files
|
||||
from mkdocs.structure.nav import Link, Section
|
||||
from mkdocs.utils import get_theme_dir
|
||||
from urllib.parse import ParseResult as URL, urlparse
|
||||
|
||||
from .builder import ProjectsBuilder
|
||||
from .config import ProjectsConfig
|
||||
from .structure import Project, ProjectLink
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Projects plugin
|
||||
class ProjectsPlugin(BasePlugin[ProjectsConfig]):
|
||||
|
||||
# Projects builder
|
||||
builder: ProjectsBuilder = None
|
||||
|
||||
# Initialize plugin
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize incremental builds
|
||||
self.is_serve = False
|
||||
self.is_dirty = False
|
||||
|
||||
# Hack: Since we're building in topological order, we cannot let MkDocs
|
||||
# clean the directory, because it means that nested projects are always
|
||||
# deleted before a project is built. We also don't need to restore this
|
||||
# functionality, because it's only used once in the process.
|
||||
utils.clean_directory = lambda _: _
|
||||
|
||||
# Determine whether we're serving the site
|
||||
def on_startup(self, *, command, dirty):
|
||||
self.is_serve = command == "serve"
|
||||
self.is_dirty = dirty
|
||||
|
||||
# Resolve projects – compared to our other concurrent plugins, this plugin
|
||||
# is forced to use a process pool in order to guarantee proper isolation, as
|
||||
# MkDocs itself is not thread-safe. Additionally, all project configurations
|
||||
# are resolved and written to the cache (if enabled), as it's sufficient to
|
||||
# resolve them once on the top-level before projects are built. We might
|
||||
# need adjacent project configurations for interlinking projects.
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built - we can only exit here if we're
|
||||
# at the top-level, but not when building a nested project
|
||||
root = self.config.projects_root_dir is None
|
||||
if root and not self.config.projects:
|
||||
return
|
||||
|
||||
# Set projects root directory to the top-level project
|
||||
if not self.config.projects_root_dir:
|
||||
self.config.projects_root_dir = os.path.dirname(
|
||||
config.config_file_path
|
||||
)
|
||||
|
||||
# Initialize manifest
|
||||
self.manifest: dict[str, str] = {}
|
||||
self.manifest_file = os.path.join(
|
||||
self.config.projects_root_dir,
|
||||
self.config.cache_dir,
|
||||
"manifest.json"
|
||||
)
|
||||
|
||||
# Load manifest if it exists and the cache should be used
|
||||
if os.path.isfile(self.manifest_file):
|
||||
try:
|
||||
with open(self.manifest_file) as f:
|
||||
self.manifest = json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Building the top-level project, we must resolve and load all project
|
||||
# configurations, as we need all information upfront to build them in
|
||||
# the correct order, and to resolve links between projects. Furthermore,
|
||||
# the author might influence a project's path by setting the site URL.
|
||||
if root:
|
||||
if not self.builder:
|
||||
self.builder = ProjectsBuilder(config, self.config)
|
||||
|
||||
# @todo: detach project resolution from build
|
||||
self.manifest = { ".": os.path.relpath(config.config_file_path) }
|
||||
for job in self.builder.root.jobs():
|
||||
path = os.path.relpath(job.project.config.config_file_path)
|
||||
self.manifest[job.project.slug] = path
|
||||
|
||||
# Save manifest, a we need it in nested projects
|
||||
os.makedirs(os.path.dirname(self.manifest_file), exist_ok = True)
|
||||
with open(self.manifest_file, "w") as f:
|
||||
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
|
||||
|
||||
# Schedule projects for building - the general case is that all projects
|
||||
# can be considered independent of each other, so we build them in parallel
|
||||
def on_pre_build(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built or we're not at the top-level
|
||||
if not self.config.projects or not self.builder:
|
||||
return
|
||||
|
||||
# Build projects
|
||||
self.builder.build(self.is_serve, self.is_dirty)
|
||||
|
||||
# Patch environment to allow for hoisting of media files provided by the
|
||||
# theme itself, which will also work for other themes, not only this one
|
||||
def on_env(self, env, *, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built or we're at the top-level
|
||||
if not self.config.projects or self.builder:
|
||||
return
|
||||
|
||||
# If hoisting is enabled and we're building a project, remove all media
|
||||
# files that are provided by the theme and hoist them to the top
|
||||
if self.config.hoisting:
|
||||
theme = get_theme_dir(config.theme.name)
|
||||
hoist = Files([])
|
||||
|
||||
# Retrieve top-level project and check if the current project uses
|
||||
# the same theme as the top-level project - if not, don't hoist
|
||||
root = Project("mkdocs.yml", self.config)
|
||||
if config.theme.name != root.config.theme["name"]:
|
||||
return
|
||||
|
||||
# Remove all media files that are provided by the theme
|
||||
for file in files.media_files():
|
||||
if file.abs_src_path.startswith(theme):
|
||||
files.remove(file)
|
||||
hoist.append(file)
|
||||
|
||||
# Resolve source and target project
|
||||
source: Project | None = None
|
||||
target: Project | None = None
|
||||
for ref, file in self.manifest.items():
|
||||
base = os.path.join(self.config.projects_root_dir, file)
|
||||
if file == os.path.relpath(
|
||||
config.config_file_path, self.config.projects_root_dir
|
||||
):
|
||||
source = Project(base, self.config, ref)
|
||||
if "." == ref:
|
||||
target = Project(base, self.config, ref)
|
||||
|
||||
# Compute path for slug from source and target project
|
||||
path = target.path(source)
|
||||
|
||||
# Fetch URL template filter from environment - the filter might
|
||||
# be overridden by other plugins, so we must retrieve and wrap it
|
||||
url_filter = env.filters["url"]
|
||||
|
||||
# Patch URL template filter to add support for correctly resolving
|
||||
# media files that were hoisted to the top-level project
|
||||
@pass_context
|
||||
def url_filter_with_hoisting(context: Context, url: str | None):
|
||||
if url and hoist.get_file_from_path(url):
|
||||
return posixpath.join(path, url_filter(context, url))
|
||||
else:
|
||||
return url_filter(context, url)
|
||||
|
||||
# Register custom template filters
|
||||
env.filters["url"] = url_filter_with_hoisting
|
||||
|
||||
# Adjust project navigation in page (run latest) - as always, allow
|
||||
# other plugins to alter the navigation before we process it here
|
||||
@event_priority(-100)
|
||||
def on_page_context(self, context, *, page, config, nav):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built
|
||||
if not self.config.projects:
|
||||
return
|
||||
|
||||
# Replace project URLs in navigation
|
||||
self._replace(nav.items, config)
|
||||
|
||||
# Adjust project navigation in template (run latest) - as always, allow
|
||||
# other plugins to alter the navigation before we process it here
|
||||
@event_priority(-100)
|
||||
def on_template_context(self, context, *, template_name, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built
|
||||
if not self.config.projects:
|
||||
return
|
||||
|
||||
# Replace project URLs in navigation
|
||||
self._replace(context["nav"].items, config)
|
||||
|
||||
# Serve projects
|
||||
def on_serve(self, server, *, config, builder):
|
||||
if self.config.enabled:
|
||||
self.builder.serve(server, self.is_dirty)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Replace project links in the given list of navigation items
|
||||
def _replace(self, items: list[StructureItem], config: MkDocsConfig):
|
||||
for index, item in enumerate(items):
|
||||
|
||||
# Handle section
|
||||
if isinstance(item, Section):
|
||||
self._replace(item.children, config)
|
||||
|
||||
# Handle link
|
||||
if isinstance(item, Link):
|
||||
url = urlparse(item.url)
|
||||
if url.scheme == "project":
|
||||
project, url = self._resolve_project_url(url, config)
|
||||
|
||||
# Append file name if directory URLs are disabled
|
||||
if not project.config.use_directory_urls:
|
||||
url += "index.html"
|
||||
|
||||
# Replace link with project link
|
||||
items[index] = ProjectLink(
|
||||
item.title or project.config.site_name,
|
||||
url
|
||||
)
|
||||
|
||||
# Resolve project URL and slug
|
||||
def _resolve_project_url(self, url: URL, config: MkDocsConfig):
|
||||
|
||||
# Abort if the project URL contains a path, as we first need to collect
|
||||
# use cases for when, how and whether we need and want to support this
|
||||
if url.path != "":
|
||||
raise PluginError(
|
||||
f"Couldn't resolve project URL: paths currently not supported\n"
|
||||
f"Please only use 'project://{url.hostname}'"
|
||||
)
|
||||
|
||||
# Compute slug from host name and convert to dot notation
|
||||
slug = url.hostname
|
||||
slug = slug if slug.startswith(".") else f".{slug}"
|
||||
|
||||
# Resolve source and target project
|
||||
source: Project | None = None
|
||||
target: Project | None = None
|
||||
for ref, file in self.manifest.items():
|
||||
base = os.path.join(self.config.projects_root_dir, file)
|
||||
if file == os.path.relpath(
|
||||
config.config_file_path, self.config.projects_root_dir
|
||||
):
|
||||
source = Project(base, self.config, ref)
|
||||
if slug == ref:
|
||||
target = Project(base, self.config, ref)
|
||||
|
||||
# Abort if slug doesn't match a known project
|
||||
if not target:
|
||||
raise PluginError(f"Couldn't find project '{slug}'")
|
||||
|
||||
# Return project slug and path
|
||||
return target, target.path(source)
|
||||
241
material/plugins/projects/structure/__init__.py
Normal file
241
material/plugins/projects/structure/__init__.py
Normal file
@@ -0,0 +1,241 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
|
||||
from copy import deepcopy
|
||||
from glob import iglob
|
||||
from material.plugins.projects.config import ProjectsConfig
|
||||
from mkdocs.structure.nav import Link
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.config.config_options import Plugins
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project
|
||||
class Project:
|
||||
|
||||
# Initialize project - note that the configuration of the projects plugin
|
||||
# of the enclosing project is necessary to resolve nested projects
|
||||
def __init__(self, file: str, plugin: ProjectsConfig, slug = "."):
|
||||
self.config, self.plugin = self._resolve(file, plugin)
|
||||
|
||||
# The slug should not be changed after initialization, as it's used for
|
||||
# correct resolution of projects and nested projects
|
||||
self.slug = slug
|
||||
|
||||
# Find and yield nested projects of the current project - the project's
|
||||
# slug is prepended to the computed slug for a simple resolution of nested
|
||||
# projects, allowing authors to use the project:// protocol for linking to
|
||||
# projects from the top-level project or nested and adjacent projects
|
||||
def __iter__(self):
|
||||
seen: list[str] = []
|
||||
|
||||
# Compute project root and base directory
|
||||
root = os.path.dirname(self.config.config_file_path)
|
||||
base = os.path.join(root, self.plugin.projects_dir)
|
||||
|
||||
# Find and yield all projects - note that we need to filter for nested
|
||||
# projects at this point, as we're only interested in the projects on
|
||||
# the next level, not in projects inside projects as they are resolved
|
||||
# recursively to preserve topological ordering. This is also why we must
|
||||
# sort the list of projects by path, ordering shorted paths first which
|
||||
# ensures that nested projects are resolved before their parents.
|
||||
glob = os.path.join(base, self.plugin.projects_config_files)
|
||||
glob = iglob(os.path.normpath(glob), recursive = True)
|
||||
for file in sorted(glob, key = os.path.dirname):
|
||||
path = os.path.join(os.path.dirname(file), "")
|
||||
if any(path.startswith(_) for _ in seen):
|
||||
continue
|
||||
else:
|
||||
seen.append(path)
|
||||
|
||||
# Extract the first level of the project's directory relative to
|
||||
# the projects directory as the computed slug of the project. This
|
||||
# allows authors to build projects whose mkdocs.yml files are not
|
||||
# located at the project root, e.g., when using git submodules.
|
||||
slug = os.path.relpath(file, base)
|
||||
slug, *_ = slug.split(os.path.sep)
|
||||
|
||||
# Normalize slug to an internal dot notation which we convert to
|
||||
# file system or URL paths when necessary. Each slug starts with
|
||||
# a dot to denote that it is resolved from the top-level project,
|
||||
# which also allows for resolving slugs in nested projects.
|
||||
root = self.slug.rstrip(".")
|
||||
slug = f"{root}.{slug}"
|
||||
|
||||
# Create and yield project
|
||||
yield Project(file, self.plugin, slug)
|
||||
|
||||
# Compute project hash
|
||||
def __hash__(self):
|
||||
return hash(self.slug)
|
||||
|
||||
# Find and yield all nested projects (excluding this project) in reverse
|
||||
# topological order, by performing a post-order traversal on the tree of
|
||||
# projects. This function returns project jobs, which are projects with
|
||||
# their immediate dependencies, to build them in the correct order.
|
||||
def jobs(self):
|
||||
stack = [*self]
|
||||
while stack:
|
||||
|
||||
# Pop project from stack and get its dependencies
|
||||
project = stack.pop()
|
||||
dependencies = [*project]
|
||||
|
||||
# Add project dependencies to stack and yield job
|
||||
stack.extend(dependencies)
|
||||
yield ProjectJob(project, dependencies)
|
||||
|
||||
# Compute relative path between two projects
|
||||
def path(self, that: Project):
|
||||
|
||||
# If both, the top-level and the current project have a site URL set,
|
||||
# compute slug from the common path of both site URLs
|
||||
if self.config.site_url and that.config.site_url:
|
||||
source = self._path_from_config(that.config)
|
||||
target = self._path_from_config(self.config)
|
||||
|
||||
# Edge case: the author has set a site URL that does not include a
|
||||
# path, so the path of the project is equal to the top-level path.
|
||||
# In this case, we need to fall back to the path computed from the
|
||||
# slug - see https://t.ly/5vqMr
|
||||
if target == source:
|
||||
target = self._path_from_slug(self.slug)
|
||||
|
||||
# Otherwise, always compute the path from the slugs of both projects,
|
||||
# as we want to support consolidation of unrelated projects
|
||||
else:
|
||||
source = self._path_from_slug(that.slug)
|
||||
target = self._path_from_slug(self.slug)
|
||||
|
||||
# Compute path between projects, and add trailing slash
|
||||
path = posixpath.relpath(target, source)
|
||||
return posixpath.join(path, "")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Resolve project and plugin configuration
|
||||
def _resolve(self, file: str, plugin: ProjectsConfig):
|
||||
config = self._resolve_config(file)
|
||||
plugin = self._resolve_plugin(config, plugin)
|
||||
|
||||
# Return project and plugin configuration
|
||||
return config, plugin
|
||||
|
||||
# Resolve project configuration
|
||||
def _resolve_config(self, file: str):
|
||||
with open(file, encoding = "utf-8-sig") as f:
|
||||
config: MkDocsConfig = MkDocsConfig(config_file_path = file)
|
||||
config.load_file(f)
|
||||
|
||||
# Return project configuration
|
||||
return config
|
||||
|
||||
# Resolve project plugin configuration
|
||||
def _resolve_plugin(self, config: MkDocsConfig, plugin: ProjectsConfig):
|
||||
|
||||
# Make sure that every project has a plugin configuration set - we need
|
||||
# to deep copy the configuration object, as it's mutated during parsing.
|
||||
# We're using an internal method of the Plugins class to ensure that we
|
||||
# always stick to the syntaxes allowed by MkDocs (list and dictionary).
|
||||
plugins = Plugins._parse_configs(deepcopy(config.plugins))
|
||||
for index, (key, settings) in enumerate(plugins):
|
||||
if not re.match(r"^(material/)?projects$", key):
|
||||
continue
|
||||
|
||||
# Forward these settings of the plugin configuration to the project,
|
||||
# as we need to build nested projects consistently
|
||||
for name in ["cache", "projects", "projects_root_dir", "hoisting"]:
|
||||
settings[name] = plugin[name]
|
||||
|
||||
# Forward these settings only if they have not been set in the
|
||||
# project configuration, as they might be overwritten by the author
|
||||
for name in ["log", "log_level"]:
|
||||
if not name in settings:
|
||||
settings[name] = plugin[name]
|
||||
|
||||
# Initialize and expand the plugin configuration, and mutate the
|
||||
# plugin collection to persist the patched configuration
|
||||
plugin: ProjectsConfig = ProjectsConfig()
|
||||
plugin.load_dict(settings)
|
||||
if isinstance(config.plugins, list):
|
||||
config.plugins[index] = { key: dict(plugin.items()) }
|
||||
else:
|
||||
config.plugins[key] = dict(plugin.items())
|
||||
|
||||
# Return project plugin configuration
|
||||
return plugin
|
||||
|
||||
# If no plugin configuration was found, add the default configuration
|
||||
# and call this function recursively to ensure that it's present
|
||||
config.plugins.append("material/projects")
|
||||
return self._resolve_plugin(config, plugin)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Compute path from given slug - split slug at dots, ignoring the first one,
|
||||
# and join the segments to a path, prefixed with a dot. This is necessary
|
||||
# to compute the common path correctly, so we can use the same logic for
|
||||
# when the path is computed from the site URL (see below).
|
||||
def _path_from_slug(self, slug: str):
|
||||
_, *segments = slug.split(".")
|
||||
return posixpath.join(".", *segments)
|
||||
|
||||
# Compute path from given project configuration - parse site URL and return
|
||||
# canonicalized path. Paths always start with a dot and trailing slashes are
|
||||
# always removed. This is necessary so that we can compute the common path
|
||||
# correctly, since the site URL might or might not contain a trailing slash.
|
||||
def _path_from_config(self, config: MkDocsConfig):
|
||||
url = urlparse(config.site_url)
|
||||
|
||||
# Remove leading slash, if any
|
||||
path = url.path
|
||||
if path.startswith("/"):
|
||||
path = path[1:]
|
||||
|
||||
# Return normalized path
|
||||
path = posixpath.normpath(path) if path else path
|
||||
return posixpath.join(".", path)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project job
|
||||
class ProjectJob:
|
||||
|
||||
# Initialize project job
|
||||
def __init__(self, project: Project, dependencies: list[Project]):
|
||||
self.project = project
|
||||
self.dependencies = dependencies
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project link
|
||||
class ProjectLink(Link):
|
||||
|
||||
# Indicate that the link points to a project
|
||||
is_project = True
|
||||
@@ -39,6 +39,10 @@ pipeline = ("stemmer", "stopWordFilter", "trimmer")
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Search field configuration
|
||||
class SearchFieldConfig(Config):
|
||||
boost = Type((int, float), default = 1.0)
|
||||
|
||||
# Search plugin configuration
|
||||
class SearchConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
@@ -47,6 +51,7 @@ class SearchConfig(Config):
|
||||
lang = Optional(LangOption())
|
||||
separator = Optional(Type(str))
|
||||
pipeline = Optional(ListOfItems(Choice(pipeline)))
|
||||
fields = Type(dict, default = {})
|
||||
|
||||
# Settings for text segmentation (Chinese)
|
||||
jieba_dict = Optional(Type(str))
|
||||
|
||||
@@ -27,9 +27,10 @@ from backrefs import bre
|
||||
from html import escape
|
||||
from html.parser import HTMLParser
|
||||
from mkdocs import utils
|
||||
from mkdocs.config.config_options import SubConfig
|
||||
from mkdocs.plugins import BasePlugin
|
||||
|
||||
from .config import SearchConfig
|
||||
from .config import SearchConfig, SearchFieldConfig
|
||||
|
||||
try:
|
||||
import jieba
|
||||
@@ -81,6 +82,19 @@ class SearchPlugin(BasePlugin[SearchConfig]):
|
||||
r"\s*,\s*", self._translate(config, "search.config.pipeline")
|
||||
)))
|
||||
|
||||
# Validate field configuration
|
||||
validator = SubConfig(SearchFieldConfig)
|
||||
for config in self.config.fields.values():
|
||||
validator.run_validation(config)
|
||||
|
||||
# Merge with default fields
|
||||
if "title" not in self.config.fields:
|
||||
self.config.fields["title"] = { "boost": 1e3 }
|
||||
if "text" not in self.config.fields:
|
||||
self.config.fields["text"] = { "boost": 1e0 }
|
||||
if "tags" not in self.config.fields:
|
||||
self.config.fields["tags"] = { "boost": 1e6 }
|
||||
|
||||
# Initialize search index
|
||||
self.search_index = SearchIndex(**self.config)
|
||||
|
||||
@@ -230,7 +244,7 @@ class SearchIndex:
|
||||
def generate_search_index(self, prev):
|
||||
config = {
|
||||
key: self.config[key]
|
||||
for key in ["lang", "separator", "pipeline"]
|
||||
for key in ["lang", "separator", "pipeline", "fields"]
|
||||
}
|
||||
|
||||
# Hack: if we're running under dirty reload, the search index will only
|
||||
|
||||
@@ -18,8 +18,11 @@
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
import os
|
||||
|
||||
from mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import Deprecated, Type
|
||||
from mkdocs.config.config_options import Deprecated, ListOfItems, Type
|
||||
from mkdocs.config.defaults import _LogLevel
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
@@ -28,21 +31,38 @@ from mkdocs.config.config_options import Deprecated, Type
|
||||
# Social plugin configuration
|
||||
class SocialConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
concurrency = Type(int, default = max(1, os.cpu_count() - 1))
|
||||
|
||||
# Settings for caching
|
||||
cache = Type(bool, default = True)
|
||||
cache_dir = Type(str, default = ".cache/plugin/social")
|
||||
|
||||
# Settings for social cards
|
||||
# Settings for logging
|
||||
log = Type(bool, default = True)
|
||||
log_level = _LogLevel(default = "warn")
|
||||
|
||||
# Settings for cards
|
||||
cards = Type(bool, default = True)
|
||||
cards_dir = Type(str, default = "assets/images/social")
|
||||
cards_layout_dir = Type(str, default = "layouts")
|
||||
cards_layout = Type(str, default = "default")
|
||||
cards_layout_options = Type(dict, default = {})
|
||||
cards_include = ListOfItems(Type(str), default = [])
|
||||
cards_exclude = ListOfItems(Type(str), default = [])
|
||||
|
||||
# Settings for debugging
|
||||
debug = Type(bool, default = False)
|
||||
debug_on_build = Type(bool, default = False)
|
||||
debug_grid = Type(bool, default = True)
|
||||
debug_grid_step = Type(int, default = 32)
|
||||
debug_color = Type(str, default = "grey")
|
||||
|
||||
# Deprecated settings
|
||||
cards_color = Deprecated(
|
||||
option_type = Type(dict, default = {}),
|
||||
message =
|
||||
"Deprecated, use 'cards_layout_options.background_color' "
|
||||
"and 'cards_layout_options.color' with 'default' layout"
|
||||
)
|
||||
)
|
||||
cards_font = Deprecated(
|
||||
option_type = Type(str),
|
||||
message = "Deprecated, use 'cards_layout_options.font_family'"
|
||||
)
|
||||
|
||||
153
material/plugins/social/layout.py
Normal file
153
material/plugins/social/layout.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import (
|
||||
Choice, DictOfItems, ListOfItems, SubConfig, Type
|
||||
)
|
||||
try:
|
||||
from PIL.Image import Image as _Image
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Options
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Options for origin
|
||||
Origin = (
|
||||
"start top", "center top", "end top",
|
||||
"start center", "center", "end center",
|
||||
"start bottom", "center bottom", "end bottom",
|
||||
"start", "end"
|
||||
)
|
||||
|
||||
# Options for overflow
|
||||
Overflow = (
|
||||
"truncate",
|
||||
"shrink"
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Size
|
||||
class Size(Config):
|
||||
width = Type(int, default = 0)
|
||||
height = Type(int, default = 0)
|
||||
|
||||
# Offset
|
||||
class Offset(Config):
|
||||
x = Type(int, default = 0)
|
||||
y = Type(int, default = 0)
|
||||
|
||||
# # -----------------------------------------------------------------------------
|
||||
|
||||
# Background
|
||||
class Background(Config):
|
||||
color = Type(str, default = "")
|
||||
image = Type(str, default = "")
|
||||
|
||||
# # -----------------------------------------------------------------------------
|
||||
|
||||
# Icon
|
||||
class Icon(Config):
|
||||
value = Type(str, default = "")
|
||||
color = Type(str, default = "")
|
||||
|
||||
# # -----------------------------------------------------------------------------
|
||||
|
||||
# Line
|
||||
class Line(Config):
|
||||
amount = Type((int, float), default = 1)
|
||||
height = Type((int, float), default = 1)
|
||||
|
||||
# Font
|
||||
class Font(Config):
|
||||
family = Type(str, default = "Roboto")
|
||||
variant = Type(str, default = "")
|
||||
style = Type(str, default = "Regular")
|
||||
|
||||
# Typography
|
||||
class Typography(Config):
|
||||
content = Type(str, default = "")
|
||||
align = Choice(Origin, default = "start top")
|
||||
overflow = Choice(Overflow, default = "truncate")
|
||||
color = Type(str, default = "")
|
||||
line = SubConfig(Line)
|
||||
font = SubConfig(Font)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Layer
|
||||
class Layer(Config):
|
||||
size = SubConfig(Size)
|
||||
offset = SubConfig(Offset)
|
||||
origin = Choice(Origin, default = "start top")
|
||||
background = SubConfig(Background)
|
||||
icon = SubConfig(Icon)
|
||||
typography = SubConfig(Typography)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Layout
|
||||
class Layout(Config):
|
||||
definitions = ListOfItems(Type(str), default = [])
|
||||
tags = DictOfItems(Type(str), default = {})
|
||||
size = SubConfig(Size)
|
||||
layers = ListOfItems(SubConfig(Layer), default = [])
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Get layer or layout size as tuple
|
||||
def get_size(layer: Layer | Layout):
|
||||
return layer.size.width, layer.size.height
|
||||
|
||||
# Get layer offset as tuple
|
||||
def get_offset(layer: Layer, image: _Image):
|
||||
x, y = layer.offset.x, layer.offset.y
|
||||
|
||||
# Compute offset from origin - if an origin is given, compute the offset
|
||||
# relative to the image and layer size to allow for flexible positioning
|
||||
if layer.origin != "start top":
|
||||
origin = re.split(r"\s+", layer.origin)
|
||||
|
||||
# Get layer size
|
||||
w, h = get_size(layer)
|
||||
|
||||
# Compute origin on x-axis
|
||||
if "start" in origin: pass
|
||||
elif "end" in origin: x += (image.width - w) - 2 * x
|
||||
elif "center" in origin: x += (image.width - w) >> 1
|
||||
|
||||
# Compute origin on y-axis
|
||||
if "top" in origin: pass
|
||||
elif "bottom" in origin: y += (image.height - h) - 2 * y
|
||||
elif "center" in origin: y += (image.height - h) >> 1
|
||||
|
||||
# Return offset
|
||||
return x, y
|
||||
File diff suppressed because it is too large
Load Diff
29
material/plugins/social/templates/__init__.py
Normal file
29
material/plugins/social/templates/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Filter for coercing everthing that is falsy to an empty string
|
||||
def x_filter(value: str | None):
|
||||
return value or ""
|
||||
244
material/plugins/social/templates/default.yml
Normal file
244
material/plugins/social/templates/default.yml
Normal file
@@ -0,0 +1,244 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Definitions
|
||||
definitions:
|
||||
|
||||
# Background image
|
||||
- &background_image >-
|
||||
{{ layout.background_image | x }}
|
||||
|
||||
# Background color (default: indigo)
|
||||
- &background_color >-
|
||||
{%- if layout.background_color -%}
|
||||
{{ layout.background_color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("primary") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set primary = palette.get("primary", "indigo") -%}
|
||||
{%- set primary = primary.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ef5552",
|
||||
"pink": "#e92063",
|
||||
"purple": "#ab47bd",
|
||||
"deep-purple": "#7e56c2",
|
||||
"indigo": "#4051b5",
|
||||
"blue": "#2094f3",
|
||||
"light-blue": "#02a6f2",
|
||||
"cyan": "#00bdd6",
|
||||
"teal": "#009485",
|
||||
"green": "#4cae4f",
|
||||
"light-green": "#8bc34b",
|
||||
"lime": "#cbdc38",
|
||||
"yellow": "#ffec3d",
|
||||
"amber": "#ffc105",
|
||||
"orange": "#ffa724",
|
||||
"deep-orange": "#ff6e42",
|
||||
"brown": "#795649",
|
||||
"grey": "#757575",
|
||||
"blue-grey": "#546d78",
|
||||
"black": "#000000",
|
||||
"white": "#ffffff"
|
||||
}[primary] or "#4051b5" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Text color (default: white)
|
||||
- &color >-
|
||||
{%- if layout.color -%}
|
||||
{{ layout.color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("primary") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set primary = palette.get("primary", "indigo") -%}
|
||||
{%- set primary = primary.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ffffff",
|
||||
"pink": "#ffffff",
|
||||
"purple": "#ffffff",
|
||||
"deep-purple": "#ffffff",
|
||||
"indigo": "#ffffff",
|
||||
"blue": "#ffffff",
|
||||
"light-blue": "#ffffff",
|
||||
"cyan": "#ffffff",
|
||||
"teal": "#ffffff",
|
||||
"green": "#ffffff",
|
||||
"light-green": "#ffffff",
|
||||
"lime": "#000000",
|
||||
"yellow": "#000000",
|
||||
"amber": "#000000",
|
||||
"orange": "#000000",
|
||||
"deep-orange": "#ffffff",
|
||||
"brown": "#ffffff",
|
||||
"grey": "#ffffff",
|
||||
"blue-grey": "#ffffff",
|
||||
"black": "#ffffff",
|
||||
"white": "#000000"
|
||||
}[primary] or "#ffffff" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Font family (default: Roboto)
|
||||
- &font_family >-
|
||||
{%- if layout.font_family -%}
|
||||
{{ layout.font_family }}
|
||||
{%- elif config.theme.font is mapping -%}
|
||||
{{ config.theme.font.get("text", "Roboto") }}
|
||||
{%- else -%}
|
||||
Roboto
|
||||
{%- endif -%}
|
||||
|
||||
# Font variant
|
||||
- &font_variant >-
|
||||
{%- if layout.font_variant -%}
|
||||
{{ layout.font_variant }}
|
||||
{%- endif -%}
|
||||
|
||||
# Site name
|
||||
- &site_name >-
|
||||
{{ config.site_name }}
|
||||
|
||||
# Page title
|
||||
- &page_title >-
|
||||
{%- if layout.title -%}
|
||||
{{ layout.title }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("title", page.title) }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page title with site name
|
||||
- &page_title_with_site_name >-
|
||||
{%- if not page.is_homepage -%}
|
||||
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
|
||||
{%- else -%}
|
||||
{{ config.site_name }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page description
|
||||
- &page_description >-
|
||||
{%- if layout.description -%}
|
||||
{{ layout.description }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("description", config.site_description) | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo
|
||||
- &logo >-
|
||||
{%- if layout.logo -%}
|
||||
{{ layout.logo }}
|
||||
{%- elif config.theme.logo -%}
|
||||
{{ config.docs_dir }}/{{ config.theme.logo }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo (icon)
|
||||
- &logo_icon >-
|
||||
{%- if not layout.logo and config.theme.icon -%}
|
||||
{{ config.theme.icon.logo | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Meta tags
|
||||
tags:
|
||||
|
||||
# Open Graph
|
||||
og:type: website
|
||||
og:title: *page_title_with_site_name
|
||||
og:description: *page_description
|
||||
og:image: "{{ image.url }}"
|
||||
og:image:type: "{{ image.type }}"
|
||||
og:image:width: "{{ image.width }}"
|
||||
og:image:height: "{{ image.height }}"
|
||||
og:url: "{{ page.canonical_url }}"
|
||||
|
||||
# Twitter
|
||||
twitter:card: summary_large_image
|
||||
twitter:title: *page_title_with_site_name
|
||||
twitter:description: *page_description
|
||||
twitter:image: "{{ image.url }}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Specification
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Card size and layers
|
||||
size: { width: 1200, height: 630 }
|
||||
layers:
|
||||
|
||||
# Background
|
||||
- background:
|
||||
image: *background_image
|
||||
color: *background_color
|
||||
|
||||
# Logo
|
||||
- size: { width: 144, height: 144 }
|
||||
offset: { x: 992, y: 64 }
|
||||
background:
|
||||
image: *logo
|
||||
icon:
|
||||
value: *logo_icon
|
||||
color: *color
|
||||
|
||||
# Site name
|
||||
- size: { width: 832, height: 42 }
|
||||
offset: { x: 64, y: 64 }
|
||||
typography:
|
||||
content: *site_name
|
||||
color: *color
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page title
|
||||
- size: { width: 832, height: 310 }
|
||||
offset: { x: 62, y: 160 }
|
||||
typography:
|
||||
content: *page_title
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 3
|
||||
height: 1.25
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page description
|
||||
- size: { width: 832, height: 64 }
|
||||
offset: { x: 64, y: 512 }
|
||||
typography:
|
||||
content: *page_description
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 2
|
||||
height: 1.5
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Regular
|
||||
234
material/plugins/social/templates/default/accent.yml
Normal file
234
material/plugins/social/templates/default/accent.yml
Normal file
@@ -0,0 +1,234 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Definitions
|
||||
definitions:
|
||||
|
||||
# Background image
|
||||
- &background_image >-
|
||||
{{ layout.background_image | x }}
|
||||
|
||||
# Background color (default: indigo)
|
||||
- &background_color >-
|
||||
{%- if layout.background_color -%}
|
||||
{{ layout.background_color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("accent") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set accent = palette.get("accent", "indigo") -%}
|
||||
{%- set accent = accent.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ff1a47",
|
||||
"pink": "#f50056",
|
||||
"purple": "#df41fb",
|
||||
"deep-purple": "#7c4dff",
|
||||
"indigo": "#526cfe",
|
||||
"blue": "#4287ff",
|
||||
"light-blue": "#0091eb",
|
||||
"cyan": "#00bad6",
|
||||
"teal": "#00bda4",
|
||||
"green": "#00c753",
|
||||
"light-green": "#63de17",
|
||||
"lime": "#b0eb00",
|
||||
"yellow": "#ffd500",
|
||||
"amber": "#ffaa00",
|
||||
"orange": "#ff9100",
|
||||
"deep-orange": "#ff6e42"
|
||||
}[accent] or "#4051b5" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Text color (default: white)
|
||||
- &color >-
|
||||
{%- if layout.color -%}
|
||||
{{ layout.color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("accent") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set accent = palette.get("accent", "indigo") -%}
|
||||
{%- set accent = accent.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ffffff",
|
||||
"pink": "#ffffff",
|
||||
"purple": "#ffffff",
|
||||
"deep-purple": "#ffffff",
|
||||
"indigo": "#ffffff",
|
||||
"blue": "#ffffff",
|
||||
"light-blue": "#ffffff",
|
||||
"cyan": "#ffffff",
|
||||
"teal": "#ffffff",
|
||||
"green": "#ffffff",
|
||||
"light-green": "#ffffff",
|
||||
"lime": "#000000",
|
||||
"yellow": "#000000",
|
||||
"amber": "#000000",
|
||||
"orange": "#000000",
|
||||
"deep-orange": "#ffffff"
|
||||
}[accent] or "#ffffff" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Font family (default: Roboto)
|
||||
- &font_family >-
|
||||
{%- if layout.font_family -%}
|
||||
{{ layout.font_family }}
|
||||
{%- elif config.theme.font is mapping -%}
|
||||
{{ config.theme.font.get("text", "Roboto") }}
|
||||
{%- else -%}
|
||||
Roboto
|
||||
{%- endif -%}
|
||||
|
||||
# Font variant
|
||||
- &font_variant >-
|
||||
{%- if layout.font_variant -%}
|
||||
{{ layout.font_variant }}
|
||||
{%- endif -%}
|
||||
|
||||
# Site name
|
||||
- &site_name >-
|
||||
{{ config.site_name }}
|
||||
|
||||
# Page title
|
||||
- &page_title >-
|
||||
{%- if layout.title -%}
|
||||
{{ layout.title }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("title", page.title) }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page title with site name
|
||||
- &page_title_with_site_name >-
|
||||
{%- if not page.is_homepage -%}
|
||||
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
|
||||
{%- else -%}
|
||||
{{ config.site_name }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page description
|
||||
- &page_description >-
|
||||
{%- if layout.description -%}
|
||||
{{ layout.description }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("description", config.site_description) | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo
|
||||
- &logo >-
|
||||
{%- if layout.logo -%}
|
||||
{{ layout.logo }}
|
||||
{%- elif config.theme.logo -%}
|
||||
{{ config.docs_dir }}/{{ config.theme.logo }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo (icon)
|
||||
- &logo_icon >-
|
||||
{%- if not layout.logo and config.theme.icon -%}
|
||||
{{ config.theme.icon.logo | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Meta tags
|
||||
tags:
|
||||
|
||||
# Open Graph
|
||||
og:type: website
|
||||
og:title: *page_title_with_site_name
|
||||
og:description: *page_description
|
||||
og:image: "{{ image.url }}"
|
||||
og:image:type: "{{ image.type }}"
|
||||
og:image:width: "{{ image.width }}"
|
||||
og:image:height: "{{ image.height }}"
|
||||
og:url: "{{ page.canonical_url }}"
|
||||
|
||||
# Twitter
|
||||
twitter:card: summary_large_image
|
||||
twitter:title: *page_title_with_site_name
|
||||
twitter:description: *page_description
|
||||
twitter:image: "{{ image.url }}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Specification
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Card size and layers
|
||||
size: { width: 1200, height: 630 }
|
||||
layers:
|
||||
|
||||
# Background
|
||||
- background:
|
||||
image: *background_image
|
||||
color: *background_color
|
||||
|
||||
# Logo
|
||||
- size: { width: 144, height: 144 }
|
||||
offset: { x: 992, y: 64 }
|
||||
background:
|
||||
image: *logo
|
||||
icon:
|
||||
value: *logo_icon
|
||||
color: *color
|
||||
|
||||
# Site name
|
||||
- size: { width: 832, height: 42 }
|
||||
offset: { x: 64, y: 64 }
|
||||
typography:
|
||||
content: *site_name
|
||||
color: *color
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page title
|
||||
- size: { width: 832, height: 310 }
|
||||
offset: { x: 62, y: 160 }
|
||||
typography:
|
||||
content: *page_title
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 3
|
||||
height: 1.25
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page description
|
||||
- size: { width: 832, height: 64 }
|
||||
offset: { x: 64, y: 512 }
|
||||
typography:
|
||||
content: *page_description
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 2
|
||||
height: 1.5
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Regular
|
||||
244
material/plugins/social/templates/default/invert.yml
Normal file
244
material/plugins/social/templates/default/invert.yml
Normal file
@@ -0,0 +1,244 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Definitions
|
||||
definitions:
|
||||
|
||||
# Background image
|
||||
- &background_image >-
|
||||
{{ layout.background_image | x }}
|
||||
|
||||
# Background color (default: white)
|
||||
- &background_color >-
|
||||
{%- if layout.background_color -%}
|
||||
{{ layout.background_color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("primary") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set primary = palette.get("primary", "indigo") -%}
|
||||
{%- set primary = primary.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ffffff",
|
||||
"pink": "#ffffff",
|
||||
"purple": "#ffffff",
|
||||
"deep-purple": "#ffffff",
|
||||
"indigo": "#ffffff",
|
||||
"blue": "#ffffff",
|
||||
"light-blue": "#ffffff",
|
||||
"cyan": "#ffffff",
|
||||
"teal": "#ffffff",
|
||||
"green": "#ffffff",
|
||||
"light-green": "#ffffff",
|
||||
"lime": "#000000",
|
||||
"yellow": "#000000",
|
||||
"amber": "#000000",
|
||||
"orange": "#000000",
|
||||
"deep-orange": "#ffffff",
|
||||
"brown": "#ffffff",
|
||||
"grey": "#ffffff",
|
||||
"blue-grey": "#ffffff",
|
||||
"black": "#ffffff",
|
||||
"white": "#000000"
|
||||
}[primary] or "#ffffff" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Text color (default: indigo)
|
||||
- &color >-
|
||||
{%- if layout.color -%}
|
||||
{{ layout.color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("primary") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set primary = palette.get("primary", "indigo") -%}
|
||||
{%- set primary = primary.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ef5552",
|
||||
"pink": "#e92063",
|
||||
"purple": "#ab47bd",
|
||||
"deep-purple": "#7e56c2",
|
||||
"indigo": "#4051b5",
|
||||
"blue": "#2094f3",
|
||||
"light-blue": "#02a6f2",
|
||||
"cyan": "#00bdd6",
|
||||
"teal": "#009485",
|
||||
"green": "#4cae4f",
|
||||
"light-green": "#8bc34b",
|
||||
"lime": "#cbdc38",
|
||||
"yellow": "#ffec3d",
|
||||
"amber": "#ffc105",
|
||||
"orange": "#ffa724",
|
||||
"deep-orange": "#ff6e42",
|
||||
"brown": "#795649",
|
||||
"grey": "#757575",
|
||||
"blue-grey": "#546d78",
|
||||
"black": "#000000",
|
||||
"white": "#ffffff"
|
||||
}[primary] or "#4051b5" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Font family (default: Roboto)
|
||||
- &font_family >-
|
||||
{%- if layout.font_family -%}
|
||||
{{ layout.font_family }}
|
||||
{%- elif config.theme.font is mapping -%}
|
||||
{{ config.theme.font.get("text", "Roboto") }}
|
||||
{%- else -%}
|
||||
Roboto
|
||||
{%- endif -%}
|
||||
|
||||
# Font variant
|
||||
- &font_variant >-
|
||||
{%- if layout.font_variant -%}
|
||||
{{ layout.font_variant }}
|
||||
{%- endif -%}
|
||||
|
||||
# Site name
|
||||
- &site_name >-
|
||||
{{ config.site_name }}
|
||||
|
||||
# Page title
|
||||
- &page_title >-
|
||||
{%- if layout.title -%}
|
||||
{{ layout.title }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("title", page.title) }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page title with site name
|
||||
- &page_title_with_site_name >-
|
||||
{%- if not page.is_homepage -%}
|
||||
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
|
||||
{%- else -%}
|
||||
{{ config.site_name }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page description
|
||||
- &page_description >-
|
||||
{%- if layout.description -%}
|
||||
{{ layout.description }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("description", config.site_description) | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo
|
||||
- &logo >-
|
||||
{%- if layout.logo -%}
|
||||
{{ layout.logo }}
|
||||
{%- elif config.theme.logo -%}
|
||||
{{ config.docs_dir }}/{{ config.theme.logo }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo (icon)
|
||||
- &logo_icon >-
|
||||
{%- if not layout.logo and config.theme.icon -%}
|
||||
{{ config.theme.icon.logo | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Meta tags
|
||||
tags:
|
||||
|
||||
# Open Graph
|
||||
og:type: website
|
||||
og:title: *page_title_with_site_name
|
||||
og:description: *page_description
|
||||
og:image: "{{ image.url }}"
|
||||
og:image:type: "{{ image.type }}"
|
||||
og:image:width: "{{ image.width }}"
|
||||
og:image:height: "{{ image.height }}"
|
||||
og:url: "{{ page.canonical_url }}"
|
||||
|
||||
# Twitter
|
||||
twitter:card: summary_large_image
|
||||
twitter:title: *page_title_with_site_name
|
||||
twitter:description: *page_description
|
||||
twitter:image: "{{ image.url }}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Specification
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Card size and layers
|
||||
size: { width: 1200, height: 630 }
|
||||
layers:
|
||||
|
||||
# Background
|
||||
- background:
|
||||
image: *background_image
|
||||
color: *background_color
|
||||
|
||||
# Logo
|
||||
- size: { width: 144, height: 144 }
|
||||
offset: { x: 992, y: 64 }
|
||||
background:
|
||||
image: *logo
|
||||
icon:
|
||||
value: *logo_icon
|
||||
color: *color
|
||||
|
||||
# Site name
|
||||
- size: { width: 832, height: 42 }
|
||||
offset: { x: 64, y: 64 }
|
||||
typography:
|
||||
content: *site_name
|
||||
color: *color
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page title
|
||||
- size: { width: 832, height: 310 }
|
||||
offset: { x: 62, y: 160 }
|
||||
typography:
|
||||
content: *page_title
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 3
|
||||
height: 1.25
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page description
|
||||
- size: { width: 832, height: 64 }
|
||||
offset: { x: 64, y: 512 }
|
||||
typography:
|
||||
content: *page_description
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 2
|
||||
height: 1.5
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Regular
|
||||
77
material/plugins/social/templates/default/only/image.yml
Normal file
77
material/plugins/social/templates/default/only/image.yml
Normal file
@@ -0,0 +1,77 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Definitions
|
||||
definitions:
|
||||
|
||||
# Background image
|
||||
- &background_image >-
|
||||
{{ layout.background_image }}
|
||||
|
||||
# Page title with site name
|
||||
- &page_title_with_site_name >-
|
||||
{%- if not page.is_homepage -%}
|
||||
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("title", page.title) }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page description
|
||||
- &page_description >-
|
||||
{%- if layout.description -%}
|
||||
{{ layout.description }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("description", config.site_description) | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Meta tags
|
||||
tags:
|
||||
|
||||
# Open Graph
|
||||
og:type: website
|
||||
og:title: *page_title_with_site_name
|
||||
og:description: *page_description
|
||||
og:image: "{{ image.url }}"
|
||||
og:image:type: "{{ image.type }}"
|
||||
og:image:width: "{{ image.width }}"
|
||||
og:image:height: "{{ image.height }}"
|
||||
og:url: "{{ page.canonical_url }}"
|
||||
|
||||
# Twitter
|
||||
twitter:card: summary_large_image
|
||||
twitter:title: *page_title_with_site_name
|
||||
twitter:description: *page_description
|
||||
twitter:image: "{{ image.url }}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Specification
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Card size and layers
|
||||
size: { width: 1200, height: 630 }
|
||||
layers:
|
||||
|
||||
# Background
|
||||
- background:
|
||||
image: *background_image
|
||||
255
material/plugins/social/templates/default/variant.yml
Normal file
255
material/plugins/social/templates/default/variant.yml
Normal file
@@ -0,0 +1,255 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Definitions
|
||||
definitions:
|
||||
|
||||
# Background image
|
||||
- &background_image >-
|
||||
{{ layout.background_image | x }}
|
||||
|
||||
# Background color (default: indigo)
|
||||
- &background_color >-
|
||||
{%- if layout.background_color -%}
|
||||
{{ layout.background_color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("primary") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set primary = palette.get("primary", "indigo") -%}
|
||||
{%- set primary = primary.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ef5552",
|
||||
"pink": "#e92063",
|
||||
"purple": "#ab47bd",
|
||||
"deep-purple": "#7e56c2",
|
||||
"indigo": "#4051b5",
|
||||
"blue": "#2094f3",
|
||||
"light-blue": "#02a6f2",
|
||||
"cyan": "#00bdd6",
|
||||
"teal": "#009485",
|
||||
"green": "#4cae4f",
|
||||
"light-green": "#8bc34b",
|
||||
"lime": "#cbdc38",
|
||||
"yellow": "#ffec3d",
|
||||
"amber": "#ffc105",
|
||||
"orange": "#ffa724",
|
||||
"deep-orange": "#ff6e42",
|
||||
"brown": "#795649",
|
||||
"grey": "#757575",
|
||||
"blue-grey": "#546d78",
|
||||
"black": "#000000",
|
||||
"white": "#ffffff"
|
||||
}[primary] or "#4051b5" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Text color (default: white)
|
||||
- &color >-
|
||||
{%- if layout.color -%}
|
||||
{{ layout.color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("primary") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set primary = palette.get("primary", "indigo") -%}
|
||||
{%- set primary = primary.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ffffff",
|
||||
"pink": "#ffffff",
|
||||
"purple": "#ffffff",
|
||||
"deep-purple": "#ffffff",
|
||||
"indigo": "#ffffff",
|
||||
"blue": "#ffffff",
|
||||
"light-blue": "#ffffff",
|
||||
"cyan": "#ffffff",
|
||||
"teal": "#ffffff",
|
||||
"green": "#ffffff",
|
||||
"light-green": "#ffffff",
|
||||
"lime": "#000000",
|
||||
"yellow": "#000000",
|
||||
"amber": "#000000",
|
||||
"orange": "#000000",
|
||||
"deep-orange": "#ffffff",
|
||||
"brown": "#ffffff",
|
||||
"grey": "#ffffff",
|
||||
"blue-grey": "#ffffff",
|
||||
"black": "#ffffff",
|
||||
"white": "#000000"
|
||||
}[primary] or "#ffffff" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Font family (default: Roboto)
|
||||
- &font_family >-
|
||||
{%- if layout.font_family -%}
|
||||
{{ layout.font_family }}
|
||||
{%- elif config.theme.font is mapping -%}
|
||||
{{ config.theme.font.get("text", "Roboto") }}
|
||||
{%- else -%}
|
||||
Roboto
|
||||
{%- endif -%}
|
||||
|
||||
# Font variant
|
||||
- &font_variant >-
|
||||
{%- if layout.font_variant -%}
|
||||
{{ layout.font_variant }}
|
||||
{%- endif -%}
|
||||
|
||||
# Site name
|
||||
- &site_name >-
|
||||
{{ config.site_name }}
|
||||
|
||||
# Page title
|
||||
- &page_title >-
|
||||
{%- if layout.title -%}
|
||||
{{ layout.title }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("title", page.title) }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page title with site name
|
||||
- &page_title_with_site_name >-
|
||||
{%- if not page.is_homepage -%}
|
||||
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
|
||||
{%- else -%}
|
||||
{{ config.site_name }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page description
|
||||
- &page_description >-
|
||||
{%- if layout.description -%}
|
||||
{{ layout.description }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("description", config.site_description) | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page icon
|
||||
- &page_icon >-
|
||||
{{ page.meta.icon | x }}
|
||||
|
||||
# Logo
|
||||
- &logo >-
|
||||
{%- if layout.logo -%}
|
||||
{{ layout.logo }}
|
||||
{%- elif config.theme.logo -%}
|
||||
{{ config.docs_dir }}/{{ config.theme.logo }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo (icon)
|
||||
- &logo_icon >-
|
||||
{%- if not layout.logo and config.theme.icon -%}
|
||||
{{ config.theme.icon.logo | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Meta tags
|
||||
tags:
|
||||
|
||||
# Open Graph
|
||||
og:type: website
|
||||
og:title: *page_title_with_site_name
|
||||
og:description: *page_description
|
||||
og:image: "{{ image.url }}"
|
||||
og:image:type: "{{ image.type }}"
|
||||
og:image:width: "{{ image.width }}"
|
||||
og:image:height: "{{ image.height }}"
|
||||
og:url: "{{ page.canonical_url }}"
|
||||
|
||||
# Twitter
|
||||
twitter:card: summary_large_image
|
||||
twitter:title: *page_title_with_site_name
|
||||
twitter:description: *page_description
|
||||
twitter:image: "{{ image.url }}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Specification
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Card size and layers
|
||||
size: { width: 1200, height: 630 }
|
||||
layers:
|
||||
|
||||
# Background
|
||||
- background:
|
||||
image: *background_image
|
||||
color: *background_color
|
||||
|
||||
# Page icon
|
||||
- size: { width: 630, height: 630 }
|
||||
offset: { x: 800, y: 0 }
|
||||
icon:
|
||||
value: *page_icon
|
||||
color: "#00000033"
|
||||
|
||||
# Logo
|
||||
- size: { width: 64, height: 64 }
|
||||
offset: { x: 64, y: 64 }
|
||||
background:
|
||||
image: *logo
|
||||
icon:
|
||||
value: *logo_icon
|
||||
color: *color
|
||||
|
||||
# Site name
|
||||
- size: { width: 768, height: 42 }
|
||||
offset: { x: 160, y: 74 }
|
||||
typography:
|
||||
content: *site_name
|
||||
color: *color
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page title
|
||||
- size: { width: 864, height: 256 }
|
||||
offset: { x: 62, y: 192 }
|
||||
typography:
|
||||
content: *page_title
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 3
|
||||
height: 1.25
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page description
|
||||
- size: { width: 864, height: 64 }
|
||||
offset: { x: 64, y: 512 }
|
||||
typography:
|
||||
content: *page_description
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 2
|
||||
height: 1.5
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Regular
|
||||
@@ -46,6 +46,8 @@ class TagsConfig(Config):
|
||||
tags_slugify = Type(Callable, default = slugify(case = "lower"))
|
||||
tags_slugify_separator = Type(str, default = "-")
|
||||
tags_slugify_format = Type(str, default = "tag:{slug}")
|
||||
tags_hierarchy = Type(bool, default = False)
|
||||
tags_hierarchy_separator = Type(str, default = "/")
|
||||
tags_sort_by = Type(Callable, default = tag_name)
|
||||
tags_sort_reverse = Type(bool, default = False)
|
||||
tags_name_property = Type(str, default = "tags")
|
||||
@@ -61,13 +63,26 @@ class TagsConfig(Config):
|
||||
listings_tags_sort_reverse = Type(bool, default = False)
|
||||
listings_directive = Type(str, default = "material/tags")
|
||||
listings_layout = Type(str, default = "default")
|
||||
listings_toc = Type(bool, default = True)
|
||||
|
||||
# Settings for shadow tags
|
||||
shadow = Type(bool, default = False)
|
||||
shadow_on_serve = Type(bool, default = True)
|
||||
shadow_tags = TagSet()
|
||||
shadow_tags_prefix = Type(str, default = "")
|
||||
shadow_tags_suffix = Type(str, default = "")
|
||||
|
||||
# Settings for export
|
||||
export = Type(bool, default = True)
|
||||
export_file = Type(str, default = "tags.json")
|
||||
export_only = Type(bool, default = False)
|
||||
|
||||
# Deprecated settings
|
||||
tags_compare = Deprecated(moved_to = "tags_sort_by")
|
||||
tags_compare_reverse = Deprecated(moved_to = "tags_sort_reverse")
|
||||
tags_pages_compare = Deprecated(moved_to = "listings_sort_by")
|
||||
tags_pages_compare_reverse = Deprecated(moved_to = "listings_sort_reverse")
|
||||
tags_file = Deprecated(
|
||||
option_type = Type(str),
|
||||
message = "This setting is not required anymore"
|
||||
tags_file = Deprecated(option_type = Type(str))
|
||||
tags_extra_files = Deprecated(
|
||||
option_type = DictOfItems(ListOfItems(Type(str)), default = {})
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ from .config import TagsConfig
|
||||
from .renderer import Renderer
|
||||
from .structure.listing.manager import ListingManager
|
||||
from .structure.mapping.manager import MappingManager
|
||||
from .structure.mapping.storage import MappingStorage
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
@@ -44,6 +45,11 @@ from .structure.mapping.manager import MappingManager
|
||||
class TagsPlugin(BasePlugin[TagsConfig]):
|
||||
"""
|
||||
A tags plugin.
|
||||
|
||||
This plugin collects tags from the front matter of pages, and builds a tag
|
||||
structure from them. The tag structure can be used to render listings on
|
||||
pages, or to just create a site-wide tags index and export all tags and
|
||||
mappings to a JSON file for consumption in another project.
|
||||
"""
|
||||
|
||||
supports_multiple_instances = True
|
||||
@@ -123,6 +129,17 @@ class TagsPlugin(BasePlugin[TagsConfig]):
|
||||
else:
|
||||
config.markdown_extensions.append("attr_list")
|
||||
|
||||
# If the author only wants to extract and export mappings, we allow to
|
||||
# disable the rendering of all tags and listings with a single setting
|
||||
if self.config.export_only:
|
||||
self.config.tags = False
|
||||
self.config.listings = False
|
||||
|
||||
# By default, shadow tags are rendered when the documentation is served,
|
||||
# but not when it is built, for a better user experience
|
||||
if self.is_serve and self.config.shadow_on_serve:
|
||||
self.config.shadow = True
|
||||
|
||||
@event_priority(-50)
|
||||
def on_page_markdown(
|
||||
self, markdown: str, *, page: Page, config: MkDocsConfig, **kwargs
|
||||
@@ -151,6 +168,10 @@ class TagsPlugin(BasePlugin[TagsConfig]):
|
||||
if self.config.tags_file:
|
||||
markdown = self._handle_deprecated_tags_file(page, markdown)
|
||||
|
||||
# Handle deprecation of `tags_extra_files` setting
|
||||
if self.config.tags_extra_files:
|
||||
markdown = self._handle_deprecated_tags_extra_files(page, markdown)
|
||||
|
||||
# Collect tags from page
|
||||
try:
|
||||
self.mappings.add(page, markdown)
|
||||
@@ -186,6 +207,15 @@ class TagsPlugin(BasePlugin[TagsConfig]):
|
||||
# Populate and render all listings
|
||||
self.listings.populate_all(self.mappings, Renderer(env, config))
|
||||
|
||||
# Export mappings to file, if enabled
|
||||
if self.config.export:
|
||||
path = os.path.join(config.site_dir, self.config.export_file)
|
||||
path = os.path.normpath(path)
|
||||
|
||||
# Serialize mappings and save to file
|
||||
storage = MappingStorage(self.config)
|
||||
storage.save(path, self.mappings)
|
||||
|
||||
def on_page_context(
|
||||
self, context: TemplateContext, *, page: Page, **kwargs
|
||||
) -> None:
|
||||
@@ -243,6 +273,38 @@ class TagsPlugin(BasePlugin[TagsConfig]):
|
||||
# Return markdown
|
||||
return markdown
|
||||
|
||||
def _handle_deprecated_tags_extra_files(
|
||||
self, page: Page, markdown: str
|
||||
) -> str:
|
||||
"""
|
||||
Handle deprecation of `tags_extra_files` setting.
|
||||
|
||||
Arguments:
|
||||
page: The page.
|
||||
"""
|
||||
directive = self.config.listings_directive
|
||||
if page.file.src_uri not in self.config.tags_extra_files:
|
||||
return markdown
|
||||
|
||||
# Compute tags to render on page
|
||||
tags = self.config.tags_extra_files[page.file.src_uri]
|
||||
if tags:
|
||||
directive += f" {{ include: [{', '.join(tags)}] }}"
|
||||
|
||||
# Try to find the legacy tags marker and replace with directive
|
||||
if "[TAGS]" in markdown:
|
||||
markdown = markdown.replace(
|
||||
"[TAGS]", f"<!-- {directive} -->"
|
||||
)
|
||||
|
||||
# Try to find the directive and add it if not present
|
||||
pattern = r"<!--\s+{directive}".format(directive = re.escape(directive))
|
||||
if not re.search(pattern, markdown):
|
||||
markdown += f"\n<!-- {directive} -->"
|
||||
|
||||
# Return markdown
|
||||
return markdown
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -192,6 +192,11 @@ class Listing:
|
||||
"""
|
||||
Add mapping to listing.
|
||||
|
||||
Mappings are only added to listings, if the listing features tags that
|
||||
are also featured in the mapping. The caller can decide whether hidden
|
||||
tags should be rendered or not, e.g., automatically set by the plugin
|
||||
when shadow tags are disabled.
|
||||
|
||||
Arguments:
|
||||
mapping: The mapping.
|
||||
hidden: Whether to add hidden tags.
|
||||
|
||||
@@ -44,6 +44,14 @@ class ListingConfig(Config):
|
||||
subsection of the documentation.
|
||||
"""
|
||||
|
||||
shadow = Optional(Type(bool))
|
||||
"""
|
||||
Whether to include shadow tags.
|
||||
|
||||
This setting allows to override the global setting for shadow tags. If this
|
||||
setting is not specified, the global `shadow` setting is used.
|
||||
"""
|
||||
|
||||
layout = Optional(Type(str))
|
||||
"""
|
||||
The layout to use for rendering the listing.
|
||||
@@ -52,6 +60,14 @@ class ListingConfig(Config):
|
||||
setting is not specified, the global `listings_layout` setting is used.
|
||||
"""
|
||||
|
||||
toc = Optional(Type(bool))
|
||||
"""
|
||||
Whether to populate the table of contents with anchor links to tags.
|
||||
|
||||
This setting allows to override the global setting for the layout. If this
|
||||
setting is not specified, the global `listings_toc` setting is used.
|
||||
"""
|
||||
|
||||
include = TagSet()
|
||||
"""
|
||||
Tags to include in the listing.
|
||||
|
||||
@@ -243,9 +243,9 @@ class ListingManager:
|
||||
page = listing.page
|
||||
assert isinstance(page.content, str)
|
||||
|
||||
# Add mappings to listing
|
||||
# Add mappings to listing, passing shadow tags configuration
|
||||
for mapping in mappings:
|
||||
listing.add(mapping)
|
||||
listing.add(mapping, hidden = listing.config.shadow)
|
||||
|
||||
# Sort listings and tags - we can only do this after all mappings have
|
||||
# been added to the listing, because the tags inside the mappings do
|
||||
@@ -376,10 +376,18 @@ class ListingManager:
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Inherit shadow tags configuration, unless explicitly set
|
||||
if not isinstance(config.shadow, bool):
|
||||
config.shadow = self.config.shadow
|
||||
|
||||
# Inherit layout configuration, unless explicitly set
|
||||
if not isinstance(config.layout, str):
|
||||
config.layout = self.config.listings_layout
|
||||
|
||||
# Inherit table of contents configuration, unless explicitly set
|
||||
if not isinstance(config.toc, bool):
|
||||
config.toc = self.config.listings_toc
|
||||
|
||||
# Return listing configuration
|
||||
return config
|
||||
|
||||
@@ -389,17 +397,29 @@ class ListingManager:
|
||||
"""
|
||||
Slugify tag.
|
||||
|
||||
If the tag hierarchy setting is enabled, the tag is expanded into a
|
||||
hierarchy of tags, all of which are then slugified and joined with the
|
||||
configured separator. Otherwise, the tag is slugified directly. This is
|
||||
necessary to keep the tag hierarchy in the slug.
|
||||
|
||||
Arguments:
|
||||
tag: The tag.
|
||||
|
||||
Returns:
|
||||
The slug.
|
||||
"""
|
||||
slugify = self.config.tags_slugify
|
||||
tags = [tag.name]
|
||||
|
||||
# Compute tag hierarchy, if configured
|
||||
hierarchy = self.config.tags_hierarchy_separator
|
||||
if self.config.tags_hierarchy:
|
||||
tags = tag.name.split(hierarchy)
|
||||
|
||||
# Slugify tag hierarchy and join with separator
|
||||
separator = self.config.tags_slugify_separator
|
||||
return self.config.tags_slugify_format.format(
|
||||
slug = self.config.tags_slugify(
|
||||
tag.name,
|
||||
self.config.tags_slugify_separator
|
||||
)
|
||||
slug = hierarchy.join(slugify(name, separator) for name in tags)
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -81,7 +81,10 @@ def populate(listing: Listing, slugify: Slugify) -> dict[Tag, AnchorLink]:
|
||||
|
||||
# Filter top-level anchor links and insert them into the page
|
||||
children = [anchors[tag] for tag in anchors if not tag.parent]
|
||||
host.children[at:at + 1] = children
|
||||
if listing.config.toc:
|
||||
host.children[at:at + 1] = children
|
||||
else:
|
||||
host.children.pop(at)
|
||||
|
||||
# Return mapping of tags to anchor links
|
||||
return anchors
|
||||
|
||||
@@ -34,6 +34,11 @@ class ListingTree:
|
||||
"""
|
||||
A listing tree.
|
||||
|
||||
Listing trees are a tree structure that represent the hierarchy of tags
|
||||
and mappings. Each tree node is a tag, and each tag can have multiple
|
||||
mappings. Additionally, each tree can have subtrees, which are typically
|
||||
called nested tags.
|
||||
|
||||
This is an internal data structure that is used to render listings. It is
|
||||
also the immediate structure that is passed to the template.
|
||||
"""
|
||||
|
||||
@@ -23,6 +23,7 @@ from __future__ import annotations
|
||||
from collections.abc import Iterator
|
||||
from material.plugins.tags.config import TagsConfig
|
||||
from material.plugins.tags.structure.mapping import Mapping
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
from material.plugins.tags.structure.tag.options import TagSet
|
||||
from mkdocs.structure.pages import Page
|
||||
|
||||
@@ -125,7 +126,7 @@ class MappingManager:
|
||||
|
||||
# Retrieve and validate tags, and add to mapping
|
||||
for tag in self.format.validate(page.meta[tags]):
|
||||
mapping.tags.add(tag)
|
||||
mapping.tags.add(self._configure(tag))
|
||||
|
||||
# Return mapping
|
||||
return mapping
|
||||
@@ -143,6 +144,90 @@ class MappingManager:
|
||||
if page.url in self.data:
|
||||
return self.data[page.url]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _configure(self, tag: Tag) -> Tag:
|
||||
"""
|
||||
Configure tag.
|
||||
|
||||
This method is called by the mapping manager to configure a tag for the
|
||||
the tag structure. Depending on the configuration, the tag is expanded
|
||||
into a hierarchy of tags, and can be marked as hidden if it is a shadow
|
||||
tag, hiding it from mappings and listings when rendering.
|
||||
|
||||
Arguments:
|
||||
tag: The tag.
|
||||
|
||||
Returns:
|
||||
The configured tag.
|
||||
"""
|
||||
if self.config.tags_hierarchy:
|
||||
return self._configure_hierarchy(tag)
|
||||
else:
|
||||
return self._configure_shadow(tag, tag.name)
|
||||
|
||||
def _configure_hierarchy(self, tag: Tag) -> Tag:
|
||||
"""
|
||||
Configure hierarchical tag.
|
||||
|
||||
Note that shadow tags that occur as part of a tag hierarchy propagate
|
||||
their hidden state to all of their children.
|
||||
|
||||
Arguments:
|
||||
tag: The tag.
|
||||
|
||||
Returns:
|
||||
The configured tag.
|
||||
"""
|
||||
separator = self.config.tags_hierarchy_separator
|
||||
root, *rest = tag.name.split(separator)
|
||||
|
||||
# Create tag root and hierarchy
|
||||
tag = self._configure_shadow(Tag(root), root)
|
||||
for name in rest:
|
||||
tag = self._configure_shadow(Tag(
|
||||
separator.join([tag.name, name]),
|
||||
parent = tag, hidden = tag.hidden
|
||||
), name)
|
||||
|
||||
# Return tag
|
||||
return tag
|
||||
|
||||
def _configure_shadow(self, tag: Tag, name: str) -> Tag:
|
||||
"""
|
||||
Configure shadow tag.
|
||||
|
||||
Regardless of the configuration, tags are always marked as hidden if
|
||||
they're classified as shadow tags, e.g., if their name matches the
|
||||
configured shadow prefix or suffix, or if they're part of the list of
|
||||
shadow tags. Whether they're displayed is decided before rendering.
|
||||
|
||||
The tag name must be passed separately, as it may be different from the
|
||||
tag's name, e.g., when creating a tag hierarchy. In this case, the name
|
||||
represents the part that was added to the tag, essentially the suffix.
|
||||
The name is checked for shadow prefixes and suffixes.
|
||||
|
||||
Arguments:
|
||||
tag: The tag.
|
||||
name: The tag name.
|
||||
|
||||
Returns:
|
||||
The configured tag.
|
||||
"""
|
||||
if not tag.hidden:
|
||||
tag.hidden = tag in self.config.shadow_tags
|
||||
|
||||
# Check if tag matches shadow prefix, if defined
|
||||
if not tag.hidden and self.config.shadow_tags_prefix:
|
||||
tag.hidden = name.startswith(self.config.shadow_tags_prefix)
|
||||
|
||||
# Check if tag matches shadow suffix, if defined
|
||||
if not tag.hidden and self.config.shadow_tags_suffix:
|
||||
tag.hidden = name.endswith(self.config.shadow_tags_suffix)
|
||||
|
||||
# Return tag
|
||||
return tag
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -31,6 +31,35 @@ from functools import total_ordering
|
||||
class Tag:
|
||||
"""
|
||||
A tag.
|
||||
|
||||
Tags can be used to categorize pages and group them into a tag structure. A
|
||||
tag is a simple string, which can be split into a hierarchy of tags by using
|
||||
the character or string as defined in the `hierarchy_separator` setting in
|
||||
`mkdocs.yml`. Each parent tag contains their child tags.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
tags:
|
||||
- foo/bar
|
||||
- foo/baz
|
||||
- qux
|
||||
```
|
||||
|
||||
The tag structure for the above example would look like this:
|
||||
|
||||
```
|
||||
.
|
||||
├─ foo
|
||||
│ ├─ bar
|
||||
│ └─ baz
|
||||
└─ qux
|
||||
```
|
||||
|
||||
Note that this class does not split the tag name into a hierarchy of tags
|
||||
by itself, but rather provides a simple interface to iterate over the tag
|
||||
and its parents. Splitting is left to the caller, in order to allow for
|
||||
changing the separator in `mkdocs.yml`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
||||
19
material/plugins/typeset/__init__.py
Normal file
19
material/plugins/typeset/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
30
material/plugins/typeset/config.py
Normal file
30
material/plugins/typeset/config.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from mkdocs.config.config_options import Type
|
||||
from mkdocs.config.base import Config
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Typeset plugin configuration
|
||||
class TypesetConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
123
material/plugins/typeset/plugin.py
Normal file
123
material/plugins/typeset/plugin.py
Normal file
@@ -0,0 +1,123 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
import re
|
||||
|
||||
from mkdocs.plugins import BasePlugin
|
||||
|
||||
from .config import TypesetConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Typeset plugin
|
||||
class TypesetPlugin(BasePlugin[TypesetConfig]):
|
||||
|
||||
# Initialize plugin
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Initialize titles
|
||||
self.title_map: dict[str, str] = {}
|
||||
|
||||
# Extract source of page title before it's lost
|
||||
def on_pre_page(self, page, *, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Check if page title was set in configuration
|
||||
if page.title:
|
||||
path = page.file.src_uri
|
||||
self.title_map[path] = "config"
|
||||
|
||||
# Extract typeset content for headlines
|
||||
def on_page_content(self, html, *, page, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Check if page title was set in metadata
|
||||
path = page.file.src_uri
|
||||
if path not in self.title_map:
|
||||
if "title" in page.meta:
|
||||
self.title_map[path] = "meta"
|
||||
|
||||
# Flatten anchors and map to headlines
|
||||
anchors = _flatten(page.toc.items)
|
||||
for (level, id, title) in re.findall(
|
||||
r"<h(\d)[^>]+id=\"([^\"]+)[^>]*>(.*?)</h\1>",
|
||||
html, flags = re.I | re.M
|
||||
):
|
||||
if id not in anchors:
|
||||
continue
|
||||
|
||||
# If the author uses `data-toc-label` to override a heading (which
|
||||
# doesn't support adding of HTML tags), we can abort here, since
|
||||
# the headline will be rendered as-is. It's more or less a hack, so
|
||||
# we should check if we can improve it in the future.
|
||||
label = re.escape(anchors[id].title)
|
||||
if re.search(rf"data-toc-label=['\"]{label}", page.markdown):
|
||||
continue
|
||||
|
||||
# Remove anchor links from headlines – we need to do that, or we
|
||||
# end up with anchor links inside anchor links, which is invalid
|
||||
# HTML5. There are two cases we need to account for here:
|
||||
#
|
||||
# 1. If toc.anchorlink is enabled, the entire headline is wrapped
|
||||
# in an anchor link, so we unpack its contents
|
||||
#
|
||||
# 2. If toc.permalink is enabled, an anchor link is appended to the
|
||||
# contents of the headline, so we just remove it
|
||||
#
|
||||
# Albeit it doesn't make much sense, both options can be used at
|
||||
# the same time, so we need to account for both cases. This problem
|
||||
# was first reported in https://bit.ly/456AjUm
|
||||
title = re.sub(r"^<a\s+[^>]+>(.*?)</a>", r"\1", title)
|
||||
title = re.sub(r"<a\s+[^>]+>[^<]+?</a>$", "", title)
|
||||
|
||||
# Remove author-provided ids - see https://bit.ly/3ngiZea
|
||||
title = re.sub(r"id=\"?[^\">]+\"?", "", title)
|
||||
|
||||
# Assign headline content to anchor
|
||||
anchors[id].typeset = { "title": title }
|
||||
if path not in self.title_map:
|
||||
|
||||
# Assign first top-level headline to page
|
||||
if not hasattr(page, "typeset") and int(level) == 1:
|
||||
page.typeset = anchors[id].typeset
|
||||
page.title = re.sub(r"<[^>]+>", "", title)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Flatten a tree of anchors
|
||||
def _flatten(items):
|
||||
anchors = {}
|
||||
for item in items:
|
||||
anchors[item.id] = item
|
||||
|
||||
# Recursively expand children
|
||||
if item.children:
|
||||
anchors.update(_flatten(item.children))
|
||||
|
||||
# Return anchors
|
||||
return anchors
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"version":3,"sources":["src/templates/assets/stylesheets/palette/_scheme.scss","../../../../src/templates/assets/stylesheets/palette.scss","src/templates/assets/stylesheets/palette/_accent.scss","src/templates/assets/stylesheets/palette/_primary.scss","src/templates/assets/stylesheets/utilities/_break.scss"],"names":[],"mappings":"AA2BA,cAGE,6BAME,sDAAA,CACA,6DAAA,CACA,+DAAA,CACA,gEAAA,CACA,mDAAA,CACA,6DAAA,CACA,+DAAA,CACA,gEAAA,CAGA,mDAAA,CACA,gDAAA,CAGA,0BAAA,CACA,mCAAA,CAGA,iCAAA,CACA,kCAAA,CACA,mCAAA,CACA,mCAAA,CACA,kCAAA,CACA,iCAAA,CACA,+CAAA,CACA,6DAAA,CACA,gEAAA,CACA,4DAAA,CACA,4DAAA,CACA,6DAAA,CAGA,6CAAA,CAGA,+CAAA,CAGA,uDAAA,CACA,6DAAA,CACA,2DAAA,CAGA,iCAAA,CAGA,yDAAA,CACA,iEAAA,CAGA,mDAAA,CACA,mDAAA,CAGA,qDAAA,CACA,uDAAA,CAGA,8DAAA,CAKA,8DAAA,CAKA,0DAAA,CAvEA,iBCeF,CD6DE,kHAEE,YC3DJ,CDkFE,yDACE,4BChFJ,CD+EE,2DACE,4BC7EJ,CD4EE,gEACE,4BC1EJ,CDyEE,2DACE,4BCvEJ,CDsEE,yDACE,4BCpEJ,CDmEE,0DACE,4BCjEJ,CDgEE,gEACE,4BC9DJ,CD6DE,0DACE,4BC3DJ,CD0DE,2OACE,4BC/CJ,CDsDA,+FAGE,iCCpDF,CACF,CC/CE,2BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD2CN,CCrDE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDkDN,CC5DE,8BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDyDN,CCnEE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDgEN,CC1EE,8BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDuEN,CCjFE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD8EN,CCxFE,kCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDqFN,CC/FE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD4FN,CCtGE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDmGN,CC7GE,6BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD0GN,CCpHE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDiHN,CC3HE,4BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCD2HN,CClIE,8BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDkIN,CCzIE,6BACE,yBAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDyIN,CChJE,8BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDgJN,CCvJE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDoJN,CEzJE,4BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFsJN,CEjKE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCF8JN,CEzKE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFsKN,CEjLE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCF8KN,CEzLE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFsLN,CEjME,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCF8LN,CEzME,mCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFsMN,CEjNE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCF8MN,CEzNE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFsNN,CEjOE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCF8NN,CEzOE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFsON,CEjPE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFiPN,CEzPE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFyPN,CEjQE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFiQN,CEzQE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFyQN,CEjRE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCF8QN,CEzRE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFsRN,CEjSE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCAAA,CAKA,4BF0RN,CE1SE,kCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCAAA,CAKA,4BFmSN,CEpRE,sEACE,4BFuRJ,CExRE,+DACE,4BF2RJ,CE5RE,iEACE,4BF+RJ,CEhSE,gEACE,4BFmSJ,CEpSE,iEACE,4BFuSJ,CE9RA,8BACE,mDAAA,CACA,4DAAA,CACA,0DAAA,CACA,oDAAA,CACA,2DAAA,CAGA,4BF+RF,CE5RE,yCACE,+BF8RJ,CE3RI,kDAEE,0CAAA,CACA,sCAAA,CAFA,mCF+RN,CG3MI,mCD1EA,+CACE,8CFwRJ,CErRI,qDACE,8CFuRN,CElRE,iEACE,mCFoRJ,CACF,CGtNI,sCDvDA,uCACE,oCFgRJ,CACF,CEvQA,8BACE,kDAAA,CACA,4DAAA,CACA,wDAAA,CACA,oDAAA,CACA,6DAAA,CAGA,4BFwQF,CErQE,yCACE,+BFuQJ,CEpQI,kDAEE,0CAAA,CACA,sCAAA,CAFA,mCFwQN,CEjQE,yCACE,6CFmQJ,CG5NI,0CDhCA,8CACE,gDF+PJ,CACF,CGjOI,0CDvBA,iFACE,6CF2PJ,CACF,CGzPI,sCDKA,uCACE,6CFuPJ,CACF","file":"palette.css"}
|
||||
@@ -27,12 +27,17 @@
|
||||
{% if page.next_page %}
|
||||
<link rel="next" href="{{ page.next_page.url | url }}">
|
||||
{% endif %}
|
||||
{% if config.extra.alternate is iterable %}
|
||||
{% for alt in config.extra.alternate %}
|
||||
<link rel="alternate" href="{{ alt.link | url }}" hreflang="{{ alt.lang | d(lang.t('language')) }}">
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if "rss" in config.plugins %}
|
||||
<link rel="alternate" type="application/rss+xml" title="{{ lang.t('rss.created') }}" href="{{ 'feed_rss_created.xml' | url }}">
|
||||
<link rel="alternate" type="application/rss+xml" title="{{ lang.t('rss.updated') }}" href="{{ 'feed_rss_updated.xml' | url }}">
|
||||
{% endif %}
|
||||
<link rel="icon" href="{{ config.theme.favicon | url }}">
|
||||
<meta name="generator" content="mkdocs-{{ mkdocs_version }}, mkdocs-material-9.6.23">
|
||||
<meta name="generator" content="mkdocs-{{ mkdocs_version }}, mkdocs-material-9.7.0">
|
||||
{% endblock %}
|
||||
{% block htmltitle %}
|
||||
{% if page.meta and page.meta.title %}
|
||||
@@ -44,10 +49,10 @@
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.d8f969c1.min.css' | url }}">
|
||||
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.css' | url }}">
|
||||
{% if config.theme.palette %}
|
||||
{% set palette = config.theme.palette %}
|
||||
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.06af60db.min.css' | url }}">
|
||||
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.css' | url }}">
|
||||
{% endif %}
|
||||
{% include "partials/icons.html" %}
|
||||
{% endblock %}
|
||||
@@ -75,11 +80,6 @@
|
||||
{% block analytics %}
|
||||
{% include "partials/integrations/analytics.html" %}
|
||||
{% endblock %}
|
||||
{% if page.meta and page.meta.meta %}
|
||||
{% for tag in page.meta.meta %}
|
||||
<meta {% for key, value in tag | items %} {{ key }}="{{value}}" {% endfor %}>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% block extrahead %}{% endblock %}
|
||||
</head>
|
||||
{% set direction = config.theme.direction or lang.t("direction") %}
|
||||
@@ -179,6 +179,9 @@
|
||||
{% endblock %}
|
||||
{% block container %}
|
||||
<div class="md-content" data-md-component="content">
|
||||
{% if "navigation.path" in features %}
|
||||
{% include "partials/path.html" %}
|
||||
{% endif %}
|
||||
<article class="md-content__inner md-typeset">
|
||||
{% block content %}
|
||||
{% include "partials/content.html" %}
|
||||
@@ -215,6 +218,7 @@
|
||||
{% endif %}
|
||||
{% block config %}
|
||||
{% set _ = namespace() %}
|
||||
{% set _.annotate = config.extra.annotate %}
|
||||
{% set _.tags = config.extra.tags %}
|
||||
{%- if config.extra.version -%}
|
||||
{%- set mike = config.plugins.mike -%}
|
||||
@@ -238,14 +242,15 @@
|
||||
"search.result.term.missing": lang.t("search.result.term.missing"),
|
||||
"select.version": lang.t("select.version")
|
||||
},
|
||||
"search": "assets/javascripts/workers/search.973d3a69.min.js" | url,
|
||||
"search": "assets/javascripts/workers/search.js" | url,
|
||||
"annotate": _.annotate or none,
|
||||
"tags": _.tags or none,
|
||||
"version": _.version or none
|
||||
} | tojson -}}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ 'assets/javascripts/bundle.f55a23d4.min.js' | url }}"></script>
|
||||
<script src="{{ 'assets/javascripts/bundle.js' | url }}"></script>
|
||||
{% for script in config.extra_javascript %}
|
||||
{{ script | script_tag }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"action.view": "Quellcode der Seite anzeigen",
|
||||
"announce.dismiss": "Nicht mehr anzeigen",
|
||||
"blog.archive": "Archiv",
|
||||
"blog.authors": "Autoren",
|
||||
"blog.categories": "Kategorien",
|
||||
"blog.categories.in": "in",
|
||||
"blog.continue": "Weiterlesen",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"action.view": "View source of this page",
|
||||
"announce.dismiss": "Don't show this again",
|
||||
"blog.archive": "Archive",
|
||||
"blog.authors": "Authors",
|
||||
"blog.categories": "Categories",
|
||||
"blog.categories.in": "in",
|
||||
"blog.continue": "Continue reading",
|
||||
|
||||
@@ -10,18 +10,30 @@
|
||||
<span class="{{ class }}"></span>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
{% macro render_title(nav_item) %}
|
||||
{% if nav_item.typeset %}
|
||||
<span class="md-typeset">
|
||||
{{ nav_item.typeset.title }}
|
||||
</span>
|
||||
{% else %}
|
||||
{{ nav_item.title }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
{% macro render_content(nav_item, ref) %}
|
||||
{% set ref = ref or nav_item %}
|
||||
{% if nav_item.meta and nav_item.meta.icon %}
|
||||
{% include ".icons/" ~ nav_item.meta.icon ~ ".svg" %}
|
||||
{% endif %}
|
||||
<span class="md-ellipsis">
|
||||
{{ ref.title }}
|
||||
{{ render_title(ref) }}
|
||||
{% if nav_item.meta and nav_item.meta.subtitle %}
|
||||
<br>
|
||||
<small>{{ nav_item.meta.subtitle }}</small>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if nav_item.meta and nav_item.encrypted %}
|
||||
{{ render_status(nav_item, "encrypted") }}
|
||||
{% endif %}
|
||||
{% if nav_item.meta and nav_item.meta.status %}
|
||||
{{ render_status(nav_item, nav_item.meta.status) }}
|
||||
{% endif %}
|
||||
@@ -113,7 +125,7 @@
|
||||
<nav class="md-nav" data-md-level="{{ level }}" aria-labelledby="{{ path }}_label" aria-expanded="{{ nav_item.active | tojson }}">
|
||||
<label class="md-nav__title" for="{{ path }}">
|
||||
<span class="md-nav__icon md-icon"></span>
|
||||
{{ nav_item.title }}
|
||||
{{ render_title(nav_item) }}
|
||||
</label>
|
||||
<ul class="md-nav__list" data-md-scrollfix>
|
||||
{% for item in nav_item.children %}
|
||||
|
||||
29
material/templates/partials/path-item.html
Normal file
29
material/templates/partials/path-item.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{#-
|
||||
This file was automatically generated - do not edit
|
||||
-#}
|
||||
{% macro render_content(nav_item) %}
|
||||
<span class="md-ellipsis">
|
||||
{{ nav_item.title }}
|
||||
</span>
|
||||
{% endmacro %}
|
||||
{% macro render(nav_item, ref) %}
|
||||
{% set ref = ref or nav_item %}
|
||||
{% if nav_item.children %}
|
||||
{% set first = nav_item.children | first %}
|
||||
{% if first.children %}
|
||||
{{ render(first, ref) }}
|
||||
{% else %}
|
||||
<li class="md-path__item">
|
||||
<a href="{{ first.url | url }}" class="md-path__link">
|
||||
{{ render_content(ref) }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="md-path__item">
|
||||
<a href="{{ nav_item.url | url }}" class="md-path__link">
|
||||
{{ render_content(ref) }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
23
material/templates/partials/path.html
Normal file
23
material/templates/partials/path.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{#-
|
||||
This file was automatically generated - do not edit
|
||||
-#}
|
||||
{% import "partials/path-item.html" as item with context %}
|
||||
{% if page.meta and page.meta.hide %}
|
||||
{% set hidden = "hidden" if "path" in page.meta.hide %}
|
||||
{% endif %}
|
||||
{% set depth = page.ancestors | length %}
|
||||
{% if nav.homepage %}
|
||||
{% set depth = depth + 1 %}
|
||||
{% endif %}
|
||||
{% if depth > 1 %}
|
||||
<nav class="md-path" aria-label="{{ lang.t('nav') }}" {{ hidden }}>
|
||||
<ol class="md-path__list">
|
||||
{% if nav.homepage %}
|
||||
{{ item.render(nav.homepage) }}
|
||||
{% endif %}
|
||||
{% for nav_item in page.ancestors | reverse %}
|
||||
{{ item.render(nav_item) }}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
@@ -41,6 +41,9 @@
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if post.config.pin %}
|
||||
<span class="md-pin"></span>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if post.config.draft %}
|
||||
<span class="md-draft">
|
||||
@@ -58,5 +61,8 @@
|
||||
</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% if post.config.pin %}
|
||||
<hr>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<nav class="md-tags" {{ hidden }}>
|
||||
{% for tag in tags %}
|
||||
{% set class = "md-tag" %}
|
||||
{% if tag.hidden %}
|
||||
{% set class = class ~ " md-tag-shadow" %}
|
||||
{% endif %}
|
||||
{% if config.extra.tags %}
|
||||
{% set class = class ~ " md-tag-icon" %}
|
||||
{% if tag.name in config.extra.tags %}
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
<li class="md-nav__item">
|
||||
<a href="{{ toc_item.url }}" class="md-nav__link">
|
||||
<span class="md-ellipsis">
|
||||
{{ toc_item.title }}
|
||||
{% if toc_item.typeset %}
|
||||
<span class="md-typeset">
|
||||
{{ toc_item.typeset.title }}
|
||||
</span>
|
||||
{% else %}
|
||||
{{ toc_item.title }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
{% if toc_item.children %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{#-
|
||||
This file was automatically generated - do not edit
|
||||
-#}
|
||||
+
|
||||
<button type="button" class="md-top md-icon" data-md-component="top" hidden>
|
||||
{% set icon = config.theme.icon.top or "material/arrow-up" %}
|
||||
{% include ".icons/" ~ icon ~ ".svg" %}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mkdocs-material",
|
||||
"version": "9.6.23",
|
||||
"version": "9.7.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mkdocs-material",
|
||||
"version": "9.6.23",
|
||||
"version": "9.7.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clipboard": "^2.0.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mkdocs-material",
|
||||
"version": "9.6.23",
|
||||
"version": "9.7.0",
|
||||
"description": "Documentation that simply works",
|
||||
"keywords": [
|
||||
"mkdocs",
|
||||
|
||||
@@ -74,6 +74,8 @@ Changelog = "https://squidfunk.github.io/mkdocs-material/changelog/"
|
||||
Issues = "https://github.com/squidfunk/mkdocs-material/issues"
|
||||
|
||||
[project.entry-points."mkdocs.plugins"]
|
||||
# The commented-out plugins are deprecated, as they turned out to be dead ends.
|
||||
# If you fork Material for MkDocs, just uncomment these lines to re-enable them.
|
||||
"material/blog" = "material.plugins.blog.plugin:BlogPlugin"
|
||||
"material/group" = "material.plugins.group.plugin:GroupPlugin"
|
||||
"material/info" = "material.plugins.info.plugin:InfoPlugin"
|
||||
@@ -81,9 +83,11 @@ Issues = "https://github.com/squidfunk/mkdocs-material/issues"
|
||||
"material/offline" = "material.plugins.offline.plugin:OfflinePlugin"
|
||||
"material/optimize" = "material.plugins.optimize.plugin:OptimizePlugin"
|
||||
"material/privacy" = "material.plugins.privacy.plugin:PrivacyPlugin"
|
||||
# "material/projects" = "material.plugins.projects.plugin:ProjectsPlugin"
|
||||
"material/search" = "material.plugins.search.plugin:SearchPlugin"
|
||||
"material/social" = "material.plugins.social.plugin:SocialPlugin"
|
||||
"material/tags" = "material.plugins.tags.plugin:TagsPlugin"
|
||||
# "material/typeset" = "material.plugins.typeset.plugin:TypesetPlugin"
|
||||
|
||||
[project.entry-points."mkdocs.themes"]
|
||||
material = "material.templates"
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
# Requirements for core
|
||||
jinja2~=3.1
|
||||
markdown~=3.2
|
||||
mkdocs~=1.6
|
||||
mkdocs-material-extensions~=1.3
|
||||
pygments~=2.16
|
||||
pymdown-extensions~=10.2
|
||||
jinja2>=3.1
|
||||
markdown>=3.2
|
||||
mkdocs>=1.6
|
||||
mkdocs-material-extensions>=1.3
|
||||
pygments>=2.16
|
||||
pymdown-extensions>=10.2
|
||||
|
||||
# Requirements for plugins
|
||||
babel~=2.10
|
||||
colorama~=0.4
|
||||
paginate~=0.5
|
||||
backrefs~=5.7.post1
|
||||
requests~=2.26
|
||||
babel>=2.10
|
||||
colorama>=0.4
|
||||
paginate>=0.5
|
||||
backrefs>=5.7.post1
|
||||
requests>=2.26
|
||||
|
||||
|
||||
19
src/plugins/projects/__init__.py
Normal file
19
src/plugins/projects/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
320
src/plugins/projects/builder/__init__.py
Normal file
320
src/plugins/projects/builder/__init__.py
Normal file
@@ -0,0 +1,320 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import Future, as_completed
|
||||
from concurrent.futures.process import ProcessPoolExecutor
|
||||
from logging import Logger
|
||||
from material.plugins.projects.config import ProjectsConfig
|
||||
from material.plugins.projects.structure import Project, ProjectJob
|
||||
from mkdocs.commands.build import build
|
||||
from mkdocs.config.base import ConfigErrors, ConfigWarnings
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import Abort
|
||||
from mkdocs.livereload import LiveReloadServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .log import (
|
||||
get_log_for,
|
||||
get_log_formatter,
|
||||
get_log_handler,
|
||||
get_log_level_for
|
||||
)
|
||||
from .watcher import ProjectsWatcher
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Projects builder
|
||||
class ProjectsBuilder:
|
||||
|
||||
# Set of projects
|
||||
projects: set[Project] = set()
|
||||
|
||||
# Projects watcher
|
||||
watcher: ProjectsWatcher | None = None
|
||||
|
||||
# Initialize projects builder - compared to our other concurrent plugins,
|
||||
# this plugin is forced to use a process pool in order to guarantee proper
|
||||
# isolation, as MkDocs itself is not thread-safe. We also need to recreate
|
||||
# the process pool on every build, or CTRL-C is broken
|
||||
def __init__(self, config: MkDocsConfig, plugin: ProjectsConfig):
|
||||
|
||||
# Initialize root project
|
||||
self.root = Project(config.config_file_path, plugin)
|
||||
self.root.config = config
|
||||
|
||||
# Initialize process pool
|
||||
self.pool = ProcessPoolExecutor
|
||||
self.pool_jobs: dict[str, Future] = {}
|
||||
|
||||
# Build projects
|
||||
def build(self, serve: bool = False, dirty: bool = False):
|
||||
self.pool = ProcessPoolExecutor(
|
||||
self.root.plugin.concurrency,
|
||||
mp_context = multiprocessing.get_context("spawn")
|
||||
)
|
||||
|
||||
# Determine projects in topological order and prepare for building
|
||||
built: list[str] = []
|
||||
queue = [*self.root.jobs()]
|
||||
for job in queue:
|
||||
_setup(job.project, self.root, serve)
|
||||
if serve:
|
||||
self._link(job.project)
|
||||
|
||||
# Schedule projects for building
|
||||
for job in reversed(queue):
|
||||
if self._schedule(job, serve, dirty):
|
||||
queue.remove(job)
|
||||
|
||||
# Build loop - iteratively build more projects while there are still
|
||||
# projects to be built, sticking to the topological order.
|
||||
while len(built) < len(self.pool_jobs):
|
||||
for future in as_completed(self.pool_jobs.values()):
|
||||
slug, errors, warnings = future.result()
|
||||
if slug in built:
|
||||
continue
|
||||
|
||||
# Mark project as built
|
||||
built.append(slug)
|
||||
|
||||
# Schedule projects for building
|
||||
for job in reversed(queue):
|
||||
if self._schedule(job, serve, dirty):
|
||||
queue.remove(job)
|
||||
|
||||
# Print errors and warnings
|
||||
for project in self.projects:
|
||||
if project.slug == slug:
|
||||
_print(get_log_for(project), errors, warnings)
|
||||
break
|
||||
|
||||
# Shutdown process pool
|
||||
self.pool.shutdown()
|
||||
if self.watcher:
|
||||
|
||||
# Update watched paths
|
||||
for project in self.projects:
|
||||
if project.slug not in built:
|
||||
self.pool_jobs.pop(project.slug, None)
|
||||
self.watcher.unwatch(project)
|
||||
else:
|
||||
self.watcher.watch(project, self.taint)
|
||||
|
||||
# Taint a project to schedule it for building
|
||||
def taint(self, project: Project):
|
||||
self.pool_jobs.pop(project.slug, None)
|
||||
|
||||
# Watch and serve projects
|
||||
def serve(self, server: LiveReloadServer, is_dirty: bool = False):
|
||||
self.watcher = ProjectsWatcher(server)
|
||||
self.watcher.watch(self.root, self.taint)
|
||||
for project in self.projects:
|
||||
self.watcher.watch(project, self.taint)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Create symlink for project if we're serving the site
|
||||
def _link(self, project: Project):
|
||||
|
||||
# Compute path for slug from current project - normalize path,
|
||||
# as paths computed from slugs or site URLs use forward slashes
|
||||
path = project.path(self.root)
|
||||
path = os.path.normpath(path)
|
||||
|
||||
# Create symbolic link, if we haven't already
|
||||
path = os.path.join(self.root.config.site_dir, path)
|
||||
if not os.path.islink(path):
|
||||
|
||||
# Ensure link target exists
|
||||
target = os.path.realpath(os.path.dirname(path))
|
||||
if not os.path.exists(target):
|
||||
os.makedirs(target, exist_ok = True)
|
||||
|
||||
# Create symbolic link
|
||||
os.makedirs(os.path.dirname(path), exist_ok = True)
|
||||
os.symlink(project.config.site_dir, path)
|
||||
|
||||
# Schedule project for building - spawn concurrent job to build the project
|
||||
# and add a future to the jobs dictionary to link build results to projects
|
||||
def _schedule(self, job: ProjectJob, serve: bool, dirty: bool):
|
||||
self.projects.add(job.project)
|
||||
|
||||
# Exit early, if project doesn't need to be re built
|
||||
if job.project.slug in self.pool_jobs:
|
||||
return True
|
||||
|
||||
# Check if dependencies have been built already, and if so, remove
|
||||
# them from the list of dependencies. If a dependency has failed to
|
||||
# build, we'll raise an exception, which will be caught by the main
|
||||
# process, and the entire build will be aborted.
|
||||
for dependency in [*job.dependencies]:
|
||||
future = self.pool_jobs[dependency.slug]
|
||||
if future.running():
|
||||
continue
|
||||
|
||||
# If the dependency has failed to build, we'll raise an exception
|
||||
# to abort the entire build, as we can't build the project itself
|
||||
# without the dependency. This will be caught by the main process.
|
||||
# Otherwise, we'll remove the dependency from the list.
|
||||
if future.exception():
|
||||
raise future.exception()
|
||||
elif future.done():
|
||||
job.dependencies.remove(dependency)
|
||||
|
||||
# If all dependencies of the project have been built, we can build
|
||||
# the project itself by spawning a concurrent job
|
||||
if not job.dependencies:
|
||||
self.pool_jobs[job.project.slug] = self.pool.submit(
|
||||
_build, job.project, serve, dirty,
|
||||
get_log_level_for(job.project)
|
||||
)
|
||||
|
||||
# Return whether the project has been scheduled
|
||||
return not job.dependencies
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Setup project by preparing it for building
|
||||
def _setup(project: Project, root: Project, serve: bool):
|
||||
assert project.slug != "."
|
||||
|
||||
# Retrieve configuration of top-level project and transform project
|
||||
transform = root.plugin.projects_config_transform
|
||||
if isinstance(transform, Callable):
|
||||
transform(project, root)
|
||||
|
||||
# If the top-level project defines a site URL, we need to make sure that the
|
||||
# site URL of the project is set as well, setting it to the path we derive
|
||||
# from the slug. This allows to define the URL independent of the entire
|
||||
# project's directory structure. If the top-level project doesn't define a
|
||||
# site URL, it might be the case that the author is building a consolidated
|
||||
# project of several nested projects that are independent, but which should
|
||||
# be bundled together for distribution. As this is a case that is quite
|
||||
# common, we're not raising a warning or error.
|
||||
path = project.path(root)
|
||||
if root.config.site_url:
|
||||
|
||||
# If the project doesn't have a site URL, compute it from the site URL
|
||||
# of the top-level project and the path derived from the slug
|
||||
if not project.config.site_url:
|
||||
project.config.site_url = posixpath.join(
|
||||
root.config.site_url,
|
||||
path
|
||||
)
|
||||
|
||||
# If we're serving the site, replace the project's host name with the
|
||||
# dev server address, so we can serve nested projects as well
|
||||
if serve:
|
||||
url = urlparse(project.config.site_url)
|
||||
url = url._replace(
|
||||
scheme = "http",
|
||||
netloc = str(root.config.dev_addr)
|
||||
)
|
||||
|
||||
# Update site URL with dev server address
|
||||
project.config.site_url = url.geturl()
|
||||
|
||||
# If we're building the site, the project's output must be written to the
|
||||
# site directory of the top-level project, so we can serve it from there
|
||||
if not serve:
|
||||
project.config.site_dir = os.path.join(
|
||||
root.config.site_dir,
|
||||
os.path.normpath(path)
|
||||
)
|
||||
|
||||
# If we're serving the site, we must fall back to symbolic links, as MkDocs
|
||||
# will empty the entire site directory every time it performs a build
|
||||
else:
|
||||
project.config.site_dir = os.path.join(
|
||||
os.path.dirname(project.config.config_file_path),
|
||||
project.config.site_dir
|
||||
)
|
||||
|
||||
# Build project - note that regardless of whether MkDocs was started in build
|
||||
# or serve mode, projects must always be built, as they're served by the root
|
||||
def _build(project: Project, serve: bool, dirty: bool, level = logging.WARN):
|
||||
config = project.config
|
||||
|
||||
# Change working directory to project root - this is necessary, or relative
|
||||
# paths used in extensions and plugins will be resolved incorrectly
|
||||
os.chdir(os.path.dirname(config.config_file_path))
|
||||
|
||||
# Validate configuration
|
||||
errors, warnings = config.validate()
|
||||
if not errors:
|
||||
|
||||
# Retrieve and configure MkDocs' logger
|
||||
log = logging.getLogger("mkdocs")
|
||||
log.setLevel(level)
|
||||
|
||||
# Hack: there seems to be an inconsistency between operating systems,
|
||||
# and it's yet unclear where this is coming from - on macOS, the MkDocs
|
||||
# default logger has no designated handler registered, but on Linux it
|
||||
# does. If there's no handler, we need to create one. If there is, we
|
||||
# must only set the formatter, as otherwise we'll end up with the same
|
||||
# message printed on two log handlers - see https://t.ly/q7UEq
|
||||
handler = get_log_handler(project)
|
||||
if not log.hasHandlers():
|
||||
log.addHandler(handler)
|
||||
else:
|
||||
for handler in log.handlers:
|
||||
handler.setFormatter(get_log_formatter(project))
|
||||
|
||||
# Build project and dispatch startup and shutdown plugin events - note
|
||||
# that we must pass the correct command to the event handler, but run
|
||||
# the build command anyway, because otherwise some plugins will not
|
||||
# run in serve mode.
|
||||
command = "serve" if serve else "build"
|
||||
config.plugins.run_event("startup", command = command, dirty = dirty)
|
||||
try:
|
||||
build(config, dirty = dirty)
|
||||
finally:
|
||||
config.plugins.run_event("shutdown")
|
||||
log.removeHandler(handler)
|
||||
|
||||
# Return slug, errors and warnings
|
||||
return project.slug, errors, warnings
|
||||
|
||||
# Print errors and warnings resulting from building a project
|
||||
def _print(log: Logger, errors: ConfigErrors, warnings: ConfigWarnings):
|
||||
|
||||
# Print warnings
|
||||
for value, message in warnings:
|
||||
log.warning(f"Config value '{value}': {message}")
|
||||
|
||||
# Print errors
|
||||
for value, message in errors:
|
||||
log.error(f"Config value '{value}': {message}")
|
||||
|
||||
# Abort if there were errors
|
||||
if errors:
|
||||
raise Abort(f"Aborted with {len(errors)} configuration errors")
|
||||
100
src/plugins/projects/builder/log.py
Normal file
100
src/plugins/projects/builder/log.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from click import style
|
||||
from logging import Filter
|
||||
from material.plugins.projects.structure import Project
|
||||
from mkdocs.__main__ import ColorFormatter
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Dirty build warning filter
|
||||
class ProjectsFilter(Filter):
|
||||
|
||||
# Filter log messages
|
||||
def filter(self, record):
|
||||
message = record.getMessage()
|
||||
return not message.startswith("A 'dirty' build")
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Retrieve logger for project
|
||||
def get_log_for(project: Project):
|
||||
log = logging.getLogger("".join(["mkdocs.material.projects", project.slug]))
|
||||
|
||||
# Ensure logger does not propagate messags to parent logger, or messages
|
||||
# will be printed multiple times, and attach handler with color formatter
|
||||
log.propagate = False
|
||||
if not log.hasHandlers():
|
||||
log.addHandler(get_log_handler(project))
|
||||
log.setLevel(get_log_level_for(project))
|
||||
|
||||
# Return logger
|
||||
return log
|
||||
|
||||
# Retrieve log level for project
|
||||
def get_log_level_for(project: Project):
|
||||
level = logging.INFO
|
||||
|
||||
# Determine log level as set in MkDocs - if the build is started with the
|
||||
# `--quiet` flag, the log level is set to `ERROR` to suppress all messages,
|
||||
# except for errors. If it's started with `--verbose`, MkDocs sets the log
|
||||
# level to `DEBUG`, the most verbose of all log levels.
|
||||
log = logging.getLogger("mkdocs")
|
||||
for handler in log.handlers:
|
||||
level = handler.level
|
||||
break
|
||||
|
||||
# Determine if MkDocs was invoked with the `--quiet` flag and the log level
|
||||
# as configured in the plugin configuration. When `--quiet` is set, or the
|
||||
# projects plugin configuration disables logging, ignore the configured log
|
||||
# level and set it to `ERROR` to suppress all messages.
|
||||
quiet = level == logging.ERROR
|
||||
level = project.plugin.log_level.upper()
|
||||
if quiet or not project.plugin.log:
|
||||
level = logging.ERROR
|
||||
|
||||
# Retun log level
|
||||
return level
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Retrieve log handler for project
|
||||
def get_log_handler(project: Project):
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(get_log_formatter(project))
|
||||
|
||||
# Add filter to suppress dirty build warning, or we'll get as many of those
|
||||
# as projects are built - one warning is surely enough, KTHXBYE
|
||||
handler.addFilter(ProjectsFilter())
|
||||
return handler
|
||||
|
||||
# Retrieve log formatter for project
|
||||
def get_log_formatter(project: Project):
|
||||
prefix = style(f"project://{project.slug}", underline = True)
|
||||
return ColorFormatter(f"[{prefix}] %(message)s")
|
||||
98
src/plugins/projects/builder/watcher/__init__.py
Normal file
98
src/plugins/projects/builder/watcher/__init__.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from collections.abc import Callable
|
||||
from material.plugins.projects.structure import Project
|
||||
from mkdocs.livereload import LiveReloadServer
|
||||
|
||||
from .handler import ProjectChanged, ProjectAddedOrRemoved
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Projects watcher
|
||||
class ProjectsWatcher:
|
||||
|
||||
# Set of watched paths
|
||||
watched: set[str] = set()
|
||||
|
||||
# Initialize projects watcher
|
||||
def __init__(self, server: LiveReloadServer):
|
||||
self.server = server
|
||||
|
||||
# Watch project and invoke function on change
|
||||
def watch(self, project: Project, fn: Callable):
|
||||
self._on_project_changed(project, fn)
|
||||
self._on_project_added_or_removed(project, fn)
|
||||
|
||||
# Stop watching project
|
||||
def unwatch(self, project: Project):
|
||||
|
||||
# Traverse all watched paths
|
||||
root = os.path.dirname(project.config.config_file_path)
|
||||
for path in [*self.watched]:
|
||||
|
||||
# Remove path from watched paths
|
||||
if path.startswith(root):
|
||||
self.server.unwatch(path)
|
||||
self.watched.remove(path)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Register event handler for project changes
|
||||
def _on_project_changed(self, project: Project, fn: Callable):
|
||||
|
||||
# Resolve project root and docs directory
|
||||
root = os.path.dirname(project.config.config_file_path)
|
||||
docs = os.path.join(root, project.config.docs_dir)
|
||||
|
||||
# Collect all paths to watch
|
||||
paths = set([docs, project.config.config_file_path])
|
||||
for path in project.config.watch:
|
||||
paths.add(os.path.join(root, path))
|
||||
|
||||
# Register event handler for unwatched paths
|
||||
handler = ProjectChanged(project, fn)
|
||||
for path in paths - self.watched:
|
||||
self.server.watch(path)
|
||||
|
||||
# Add path and its descendents to watched paths
|
||||
self.server.observer.schedule(handler, path, recursive = True)
|
||||
self.watched.add(path)
|
||||
|
||||
# Register event handler for project additions and removals
|
||||
def _on_project_added_or_removed(self, project: Project, fn: Callable):
|
||||
|
||||
# Resolve project root and path to projects directory
|
||||
root = os.path.dirname(project.config.config_file_path)
|
||||
path = os.path.join(root, project.plugin.projects_dir)
|
||||
|
||||
# Register event handler for unwatched paths
|
||||
handler = ProjectAddedOrRemoved(project, fn)
|
||||
if path not in self.watched and os.path.isdir(path):
|
||||
|
||||
# Add path to watched paths
|
||||
self.server.observer.schedule(handler, path)
|
||||
self.watched.add(path)
|
||||
105
src/plugins/projects/builder/watcher/handler.py
Normal file
105
src/plugins/projects/builder/watcher/handler.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from collections.abc import Callable
|
||||
from material.plugins.projects.structure import Project
|
||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project changed
|
||||
class ProjectChanged(FileSystemEventHandler):
|
||||
|
||||
# Initialize event handler
|
||||
def __init__(self, project: Project, handler: Callable):
|
||||
self.project = project
|
||||
self.handler = handler
|
||||
|
||||
# Handle file event
|
||||
def on_any_event(self, event: FileSystemEvent):
|
||||
self._handle(event)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Invoke file event handler
|
||||
def _handle(self, event: FileSystemEvent):
|
||||
config = self.project.config
|
||||
|
||||
# Resolve path to docs directory
|
||||
base = os.path.dirname(config.config_file_path)
|
||||
docs = os.path.join(base, config.docs_dir)
|
||||
|
||||
# Resolve project root and path to changed file
|
||||
root = os.path.relpath(base)
|
||||
path = os.path.relpath(event.src_path, root)
|
||||
|
||||
# Check if mkdocs.yml or docs directory was deleted
|
||||
if event.src_path in [docs, config.config_file_path]:
|
||||
if event.event_type == "deleted":
|
||||
return
|
||||
|
||||
# Invoke handler and print message that we're scheduling a build
|
||||
log.info(f"Schedule build due to '{path}' in '{root}'")
|
||||
self.handler(self.project)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project added or removed
|
||||
class ProjectAddedOrRemoved(FileSystemEventHandler):
|
||||
|
||||
# Initialize event handler
|
||||
def __init__(self, project: Project, handler: Callable):
|
||||
self.project = project
|
||||
self.handler = handler
|
||||
|
||||
# Handle file creation event
|
||||
def on_created(self, event: FileSystemEvent):
|
||||
self._handle(event)
|
||||
|
||||
# Handle file deletion event
|
||||
def on_deleted(self, event: FileSystemEvent):
|
||||
self._handle(event)
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
# Invoke file event handler
|
||||
def _handle(self, event: FileSystemEvent):
|
||||
config = self.project.config
|
||||
|
||||
# Touch mkdocs.yml to trigger rebuild
|
||||
if os.path.isfile(config.config_file_path):
|
||||
os.utime(config.config_file_path, None)
|
||||
|
||||
# Invoke handler
|
||||
self.handler(self.project)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs")
|
||||
64
src/plugins/projects/config.py
Normal file
64
src/plugins/projects/config.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
import os
|
||||
|
||||
from collections.abc import Callable
|
||||
from mkdocs.config.config_options import Choice, Optional, Type
|
||||
from mkdocs.config.base import Config
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Options
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Options for log level
|
||||
LogLevel = (
|
||||
"error",
|
||||
"warn",
|
||||
"info",
|
||||
"debug"
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Projects plugin configuration
|
||||
class ProjectsConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
concurrency = Type(int, default = max(1, os.cpu_count() - 1))
|
||||
|
||||
# Settings for caching
|
||||
cache = Type(bool, default = True)
|
||||
cache_dir = Type(str, default = ".cache/plugin/projects")
|
||||
|
||||
# Settings for logging
|
||||
log = Type(bool, default = True)
|
||||
log_level = Choice(LogLevel, default = "info")
|
||||
|
||||
# Settings for projects
|
||||
projects = Type(bool, default = True)
|
||||
projects_dir = Type(str, default = "projects")
|
||||
projects_config_files = Type(str, default = "*/mkdocs.yml")
|
||||
projects_config_transform = Optional(Type(Callable))
|
||||
projects_root_dir = Optional(Type(str))
|
||||
|
||||
# Settings for hoisting
|
||||
hoisting = Type(bool, default = True)
|
||||
292
src/plugins/projects/plugin.py
Normal file
292
src/plugins/projects/plugin.py
Normal file
@@ -0,0 +1,292 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
from jinja2 import pass_context
|
||||
from jinja2.runtime import Context
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs import utils
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from mkdocs.structure import StructureItem
|
||||
from mkdocs.structure.files import Files
|
||||
from mkdocs.structure.nav import Link, Section
|
||||
from mkdocs.utils import get_theme_dir
|
||||
from urllib.parse import ParseResult as URL, urlparse
|
||||
|
||||
from .builder import ProjectsBuilder
|
||||
from .config import ProjectsConfig
|
||||
from .structure import Project, ProjectLink
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Projects plugin
|
||||
class ProjectsPlugin(BasePlugin[ProjectsConfig]):
|
||||
|
||||
# Projects builder
|
||||
builder: ProjectsBuilder = None
|
||||
|
||||
# Initialize plugin
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize incremental builds
|
||||
self.is_serve = False
|
||||
self.is_dirty = False
|
||||
|
||||
# Hack: Since we're building in topological order, we cannot let MkDocs
|
||||
# clean the directory, because it means that nested projects are always
|
||||
# deleted before a project is built. We also don't need to restore this
|
||||
# functionality, because it's only used once in the process.
|
||||
utils.clean_directory = lambda _: _
|
||||
|
||||
# Determine whether we're serving the site
|
||||
def on_startup(self, *, command, dirty):
|
||||
self.is_serve = command == "serve"
|
||||
self.is_dirty = dirty
|
||||
|
||||
# Resolve projects – compared to our other concurrent plugins, this plugin
|
||||
# is forced to use a process pool in order to guarantee proper isolation, as
|
||||
# MkDocs itself is not thread-safe. Additionally, all project configurations
|
||||
# are resolved and written to the cache (if enabled), as it's sufficient to
|
||||
# resolve them once on the top-level before projects are built. We might
|
||||
# need adjacent project configurations for interlinking projects.
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built - we can only exit here if we're
|
||||
# at the top-level, but not when building a nested project
|
||||
root = self.config.projects_root_dir is None
|
||||
if root and not self.config.projects:
|
||||
return
|
||||
|
||||
# Set projects root directory to the top-level project
|
||||
if not self.config.projects_root_dir:
|
||||
self.config.projects_root_dir = os.path.dirname(
|
||||
config.config_file_path
|
||||
)
|
||||
|
||||
# Initialize manifest
|
||||
self.manifest: dict[str, str] = {}
|
||||
self.manifest_file = os.path.join(
|
||||
self.config.projects_root_dir,
|
||||
self.config.cache_dir,
|
||||
"manifest.json"
|
||||
)
|
||||
|
||||
# Load manifest if it exists and the cache should be used
|
||||
if os.path.isfile(self.manifest_file):
|
||||
try:
|
||||
with open(self.manifest_file) as f:
|
||||
self.manifest = json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Building the top-level project, we must resolve and load all project
|
||||
# configurations, as we need all information upfront to build them in
|
||||
# the correct order, and to resolve links between projects. Furthermore,
|
||||
# the author might influence a project's path by setting the site URL.
|
||||
if root:
|
||||
if not self.builder:
|
||||
self.builder = ProjectsBuilder(config, self.config)
|
||||
|
||||
# @todo: detach project resolution from build
|
||||
self.manifest = { ".": os.path.relpath(config.config_file_path) }
|
||||
for job in self.builder.root.jobs():
|
||||
path = os.path.relpath(job.project.config.config_file_path)
|
||||
self.manifest[job.project.slug] = path
|
||||
|
||||
# Save manifest, a we need it in nested projects
|
||||
os.makedirs(os.path.dirname(self.manifest_file), exist_ok = True)
|
||||
with open(self.manifest_file, "w") as f:
|
||||
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
|
||||
|
||||
# Schedule projects for building - the general case is that all projects
|
||||
# can be considered independent of each other, so we build them in parallel
|
||||
def on_pre_build(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built or we're not at the top-level
|
||||
if not self.config.projects or not self.builder:
|
||||
return
|
||||
|
||||
# Build projects
|
||||
self.builder.build(self.is_serve, self.is_dirty)
|
||||
|
||||
# Patch environment to allow for hoisting of media files provided by the
|
||||
# theme itself, which will also work for other themes, not only this one
|
||||
def on_env(self, env, *, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built or we're at the top-level
|
||||
if not self.config.projects or self.builder:
|
||||
return
|
||||
|
||||
# If hoisting is enabled and we're building a project, remove all media
|
||||
# files that are provided by the theme and hoist them to the top
|
||||
if self.config.hoisting:
|
||||
theme = get_theme_dir(config.theme.name)
|
||||
hoist = Files([])
|
||||
|
||||
# Retrieve top-level project and check if the current project uses
|
||||
# the same theme as the top-level project - if not, don't hoist
|
||||
root = Project("mkdocs.yml", self.config)
|
||||
if config.theme.name != root.config.theme["name"]:
|
||||
return
|
||||
|
||||
# Remove all media files that are provided by the theme
|
||||
for file in files.media_files():
|
||||
if file.abs_src_path.startswith(theme):
|
||||
files.remove(file)
|
||||
hoist.append(file)
|
||||
|
||||
# Resolve source and target project
|
||||
source: Project | None = None
|
||||
target: Project | None = None
|
||||
for ref, file in self.manifest.items():
|
||||
base = os.path.join(self.config.projects_root_dir, file)
|
||||
if file == os.path.relpath(
|
||||
config.config_file_path, self.config.projects_root_dir
|
||||
):
|
||||
source = Project(base, self.config, ref)
|
||||
if "." == ref:
|
||||
target = Project(base, self.config, ref)
|
||||
|
||||
# Compute path for slug from source and target project
|
||||
path = target.path(source)
|
||||
|
||||
# Fetch URL template filter from environment - the filter might
|
||||
# be overridden by other plugins, so we must retrieve and wrap it
|
||||
url_filter = env.filters["url"]
|
||||
|
||||
# Patch URL template filter to add support for correctly resolving
|
||||
# media files that were hoisted to the top-level project
|
||||
@pass_context
|
||||
def url_filter_with_hoisting(context: Context, url: str | None):
|
||||
if url and hoist.get_file_from_path(url):
|
||||
return posixpath.join(path, url_filter(context, url))
|
||||
else:
|
||||
return url_filter(context, url)
|
||||
|
||||
# Register custom template filters
|
||||
env.filters["url"] = url_filter_with_hoisting
|
||||
|
||||
# Adjust project navigation in page (run latest) - as always, allow
|
||||
# other plugins to alter the navigation before we process it here
|
||||
@event_priority(-100)
|
||||
def on_page_context(self, context, *, page, config, nav):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built
|
||||
if not self.config.projects:
|
||||
return
|
||||
|
||||
# Replace project URLs in navigation
|
||||
self._replace(nav.items, config)
|
||||
|
||||
# Adjust project navigation in template (run latest) - as always, allow
|
||||
# other plugins to alter the navigation before we process it here
|
||||
@event_priority(-100)
|
||||
def on_template_context(self, context, *, template_name, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built
|
||||
if not self.config.projects:
|
||||
return
|
||||
|
||||
# Replace project URLs in navigation
|
||||
self._replace(context["nav"].items, config)
|
||||
|
||||
# Serve projects
|
||||
def on_serve(self, server, *, config, builder):
|
||||
if self.config.enabled:
|
||||
self.builder.serve(server, self.is_dirty)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Replace project links in the given list of navigation items
|
||||
def _replace(self, items: list[StructureItem], config: MkDocsConfig):
|
||||
for index, item in enumerate(items):
|
||||
|
||||
# Handle section
|
||||
if isinstance(item, Section):
|
||||
self._replace(item.children, config)
|
||||
|
||||
# Handle link
|
||||
if isinstance(item, Link):
|
||||
url = urlparse(item.url)
|
||||
if url.scheme == "project":
|
||||
project, url = self._resolve_project_url(url, config)
|
||||
|
||||
# Append file name if directory URLs are disabled
|
||||
if not project.config.use_directory_urls:
|
||||
url += "index.html"
|
||||
|
||||
# Replace link with project link
|
||||
items[index] = ProjectLink(
|
||||
item.title or project.config.site_name,
|
||||
url
|
||||
)
|
||||
|
||||
# Resolve project URL and slug
|
||||
def _resolve_project_url(self, url: URL, config: MkDocsConfig):
|
||||
|
||||
# Abort if the project URL contains a path, as we first need to collect
|
||||
# use cases for when, how and whether we need and want to support this
|
||||
if url.path != "":
|
||||
raise PluginError(
|
||||
f"Couldn't resolve project URL: paths currently not supported\n"
|
||||
f"Please only use 'project://{url.hostname}'"
|
||||
)
|
||||
|
||||
# Compute slug from host name and convert to dot notation
|
||||
slug = url.hostname
|
||||
slug = slug if slug.startswith(".") else f".{slug}"
|
||||
|
||||
# Resolve source and target project
|
||||
source: Project | None = None
|
||||
target: Project | None = None
|
||||
for ref, file in self.manifest.items():
|
||||
base = os.path.join(self.config.projects_root_dir, file)
|
||||
if file == os.path.relpath(
|
||||
config.config_file_path, self.config.projects_root_dir
|
||||
):
|
||||
source = Project(base, self.config, ref)
|
||||
if slug == ref:
|
||||
target = Project(base, self.config, ref)
|
||||
|
||||
# Abort if slug doesn't match a known project
|
||||
if not target:
|
||||
raise PluginError(f"Couldn't find project '{slug}'")
|
||||
|
||||
# Return project slug and path
|
||||
return target, target.path(source)
|
||||
241
src/plugins/projects/structure/__init__.py
Normal file
241
src/plugins/projects/structure/__init__.py
Normal file
@@ -0,0 +1,241 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
|
||||
from copy import deepcopy
|
||||
from glob import iglob
|
||||
from material.plugins.projects.config import ProjectsConfig
|
||||
from mkdocs.structure.nav import Link
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.config.config_options import Plugins
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project
|
||||
class Project:
|
||||
|
||||
# Initialize project - note that the configuration of the projects plugin
|
||||
# of the enclosing project is necessary to resolve nested projects
|
||||
def __init__(self, file: str, plugin: ProjectsConfig, slug = "."):
|
||||
self.config, self.plugin = self._resolve(file, plugin)
|
||||
|
||||
# The slug should not be changed after initialization, as it's used for
|
||||
# correct resolution of projects and nested projects
|
||||
self.slug = slug
|
||||
|
||||
# Find and yield nested projects of the current project - the project's
|
||||
# slug is prepended to the computed slug for a simple resolution of nested
|
||||
# projects, allowing authors to use the project:// protocol for linking to
|
||||
# projects from the top-level project or nested and adjacent projects
|
||||
def __iter__(self):
|
||||
seen: list[str] = []
|
||||
|
||||
# Compute project root and base directory
|
||||
root = os.path.dirname(self.config.config_file_path)
|
||||
base = os.path.join(root, self.plugin.projects_dir)
|
||||
|
||||
# Find and yield all projects - note that we need to filter for nested
|
||||
# projects at this point, as we're only interested in the projects on
|
||||
# the next level, not in projects inside projects as they are resolved
|
||||
# recursively to preserve topological ordering. This is also why we must
|
||||
# sort the list of projects by path, ordering shorted paths first which
|
||||
# ensures that nested projects are resolved before their parents.
|
||||
glob = os.path.join(base, self.plugin.projects_config_files)
|
||||
glob = iglob(os.path.normpath(glob), recursive = True)
|
||||
for file in sorted(glob, key = os.path.dirname):
|
||||
path = os.path.join(os.path.dirname(file), "")
|
||||
if any(path.startswith(_) for _ in seen):
|
||||
continue
|
||||
else:
|
||||
seen.append(path)
|
||||
|
||||
# Extract the first level of the project's directory relative to
|
||||
# the projects directory as the computed slug of the project. This
|
||||
# allows authors to build projects whose mkdocs.yml files are not
|
||||
# located at the project root, e.g., when using git submodules.
|
||||
slug = os.path.relpath(file, base)
|
||||
slug, *_ = slug.split(os.path.sep)
|
||||
|
||||
# Normalize slug to an internal dot notation which we convert to
|
||||
# file system or URL paths when necessary. Each slug starts with
|
||||
# a dot to denote that it is resolved from the top-level project,
|
||||
# which also allows for resolving slugs in nested projects.
|
||||
root = self.slug.rstrip(".")
|
||||
slug = f"{root}.{slug}"
|
||||
|
||||
# Create and yield project
|
||||
yield Project(file, self.plugin, slug)
|
||||
|
||||
# Compute project hash
|
||||
def __hash__(self):
|
||||
return hash(self.slug)
|
||||
|
||||
# Find and yield all nested projects (excluding this project) in reverse
|
||||
# topological order, by performing a post-order traversal on the tree of
|
||||
# projects. This function returns project jobs, which are projects with
|
||||
# their immediate dependencies, to build them in the correct order.
|
||||
def jobs(self):
|
||||
stack = [*self]
|
||||
while stack:
|
||||
|
||||
# Pop project from stack and get its dependencies
|
||||
project = stack.pop()
|
||||
dependencies = [*project]
|
||||
|
||||
# Add project dependencies to stack and yield job
|
||||
stack.extend(dependencies)
|
||||
yield ProjectJob(project, dependencies)
|
||||
|
||||
# Compute relative path between two projects
|
||||
def path(self, that: Project):
|
||||
|
||||
# If both, the top-level and the current project have a site URL set,
|
||||
# compute slug from the common path of both site URLs
|
||||
if self.config.site_url and that.config.site_url:
|
||||
source = self._path_from_config(that.config)
|
||||
target = self._path_from_config(self.config)
|
||||
|
||||
# Edge case: the author has set a site URL that does not include a
|
||||
# path, so the path of the project is equal to the top-level path.
|
||||
# In this case, we need to fall back to the path computed from the
|
||||
# slug - see https://t.ly/5vqMr
|
||||
if target == source:
|
||||
target = self._path_from_slug(self.slug)
|
||||
|
||||
# Otherwise, always compute the path from the slugs of both projects,
|
||||
# as we want to support consolidation of unrelated projects
|
||||
else:
|
||||
source = self._path_from_slug(that.slug)
|
||||
target = self._path_from_slug(self.slug)
|
||||
|
||||
# Compute path between projects, and add trailing slash
|
||||
path = posixpath.relpath(target, source)
|
||||
return posixpath.join(path, "")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Resolve project and plugin configuration
|
||||
def _resolve(self, file: str, plugin: ProjectsConfig):
|
||||
config = self._resolve_config(file)
|
||||
plugin = self._resolve_plugin(config, plugin)
|
||||
|
||||
# Return project and plugin configuration
|
||||
return config, plugin
|
||||
|
||||
# Resolve project configuration
|
||||
def _resolve_config(self, file: str):
|
||||
with open(file, encoding = "utf-8-sig") as f:
|
||||
config: MkDocsConfig = MkDocsConfig(config_file_path = file)
|
||||
config.load_file(f)
|
||||
|
||||
# Return project configuration
|
||||
return config
|
||||
|
||||
# Resolve project plugin configuration
|
||||
def _resolve_plugin(self, config: MkDocsConfig, plugin: ProjectsConfig):
|
||||
|
||||
# Make sure that every project has a plugin configuration set - we need
|
||||
# to deep copy the configuration object, as it's mutated during parsing.
|
||||
# We're using an internal method of the Plugins class to ensure that we
|
||||
# always stick to the syntaxes allowed by MkDocs (list and dictionary).
|
||||
plugins = Plugins._parse_configs(deepcopy(config.plugins))
|
||||
for index, (key, settings) in enumerate(plugins):
|
||||
if not re.match(r"^(material/)?projects$", key):
|
||||
continue
|
||||
|
||||
# Forward these settings of the plugin configuration to the project,
|
||||
# as we need to build nested projects consistently
|
||||
for name in ["cache", "projects", "projects_root_dir", "hoisting"]:
|
||||
settings[name] = plugin[name]
|
||||
|
||||
# Forward these settings only if they have not been set in the
|
||||
# project configuration, as they might be overwritten by the author
|
||||
for name in ["log", "log_level"]:
|
||||
if not name in settings:
|
||||
settings[name] = plugin[name]
|
||||
|
||||
# Initialize and expand the plugin configuration, and mutate the
|
||||
# plugin collection to persist the patched configuration
|
||||
plugin: ProjectsConfig = ProjectsConfig()
|
||||
plugin.load_dict(settings)
|
||||
if isinstance(config.plugins, list):
|
||||
config.plugins[index] = { key: dict(plugin.items()) }
|
||||
else:
|
||||
config.plugins[key] = dict(plugin.items())
|
||||
|
||||
# Return project plugin configuration
|
||||
return plugin
|
||||
|
||||
# If no plugin configuration was found, add the default configuration
|
||||
# and call this function recursively to ensure that it's present
|
||||
config.plugins.append("material/projects")
|
||||
return self._resolve_plugin(config, plugin)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Compute path from given slug - split slug at dots, ignoring the first one,
|
||||
# and join the segments to a path, prefixed with a dot. This is necessary
|
||||
# to compute the common path correctly, so we can use the same logic for
|
||||
# when the path is computed from the site URL (see below).
|
||||
def _path_from_slug(self, slug: str):
|
||||
_, *segments = slug.split(".")
|
||||
return posixpath.join(".", *segments)
|
||||
|
||||
# Compute path from given project configuration - parse site URL and return
|
||||
# canonicalized path. Paths always start with a dot and trailing slashes are
|
||||
# always removed. This is necessary so that we can compute the common path
|
||||
# correctly, since the site URL might or might not contain a trailing slash.
|
||||
def _path_from_config(self, config: MkDocsConfig):
|
||||
url = urlparse(config.site_url)
|
||||
|
||||
# Remove leading slash, if any
|
||||
path = url.path
|
||||
if path.startswith("/"):
|
||||
path = path[1:]
|
||||
|
||||
# Return normalized path
|
||||
path = posixpath.normpath(path) if path else path
|
||||
return posixpath.join(".", path)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project job
|
||||
class ProjectJob:
|
||||
|
||||
# Initialize project job
|
||||
def __init__(self, project: Project, dependencies: list[Project]):
|
||||
self.project = project
|
||||
self.dependencies = dependencies
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project link
|
||||
class ProjectLink(Link):
|
||||
|
||||
# Indicate that the link points to a project
|
||||
is_project = True
|
||||
19
src/plugins/typeset/__init__.py
Normal file
19
src/plugins/typeset/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
30
src/plugins/typeset/config.py
Normal file
30
src/plugins/typeset/config.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from mkdocs.config.config_options import Type
|
||||
from mkdocs.config.base import Config
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Typeset plugin configuration
|
||||
class TypesetConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
123
src/plugins/typeset/plugin.py
Normal file
123
src/plugins/typeset/plugin.py
Normal file
@@ -0,0 +1,123 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
import re
|
||||
|
||||
from mkdocs.plugins import BasePlugin
|
||||
|
||||
from .config import TypesetConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Typeset plugin
|
||||
class TypesetPlugin(BasePlugin[TypesetConfig]):
|
||||
|
||||
# Initialize plugin
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Initialize titles
|
||||
self.title_map: dict[str, str] = {}
|
||||
|
||||
# Extract source of page title before it's lost
|
||||
def on_pre_page(self, page, *, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Check if page title was set in configuration
|
||||
if page.title:
|
||||
path = page.file.src_uri
|
||||
self.title_map[path] = "config"
|
||||
|
||||
# Extract typeset content for headlines
|
||||
def on_page_content(self, html, *, page, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Check if page title was set in metadata
|
||||
path = page.file.src_uri
|
||||
if path not in self.title_map:
|
||||
if "title" in page.meta:
|
||||
self.title_map[path] = "meta"
|
||||
|
||||
# Flatten anchors and map to headlines
|
||||
anchors = _flatten(page.toc.items)
|
||||
for (level, id, title) in re.findall(
|
||||
r"<h(\d)[^>]+id=\"([^\"]+)[^>]*>(.*?)</h\1>",
|
||||
html, flags = re.I | re.M
|
||||
):
|
||||
if id not in anchors:
|
||||
continue
|
||||
|
||||
# If the author uses `data-toc-label` to override a heading (which
|
||||
# doesn't support adding of HTML tags), we can abort here, since
|
||||
# the headline will be rendered as-is. It's more or less a hack, so
|
||||
# we should check if we can improve it in the future.
|
||||
label = re.escape(anchors[id].title)
|
||||
if re.search(rf"data-toc-label=['\"]{label}", page.markdown):
|
||||
continue
|
||||
|
||||
# Remove anchor links from headlines – we need to do that, or we
|
||||
# end up with anchor links inside anchor links, which is invalid
|
||||
# HTML5. There are two cases we need to account for here:
|
||||
#
|
||||
# 1. If toc.anchorlink is enabled, the entire headline is wrapped
|
||||
# in an anchor link, so we unpack its contents
|
||||
#
|
||||
# 2. If toc.permalink is enabled, an anchor link is appended to the
|
||||
# contents of the headline, so we just remove it
|
||||
#
|
||||
# Albeit it doesn't make much sense, both options can be used at
|
||||
# the same time, so we need to account for both cases. This problem
|
||||
# was first reported in https://bit.ly/456AjUm
|
||||
title = re.sub(r"^<a\s+[^>]+>(.*?)</a>", r"\1", title)
|
||||
title = re.sub(r"<a\s+[^>]+>[^<]+?</a>$", "", title)
|
||||
|
||||
# Remove author-provided ids - see https://bit.ly/3ngiZea
|
||||
title = re.sub(r"id=\"?[^\">]+\"?", "", title)
|
||||
|
||||
# Assign headline content to anchor
|
||||
anchors[id].typeset = { "title": title }
|
||||
if path not in self.title_map:
|
||||
|
||||
# Assign first top-level headline to page
|
||||
if not hasattr(page, "typeset") and int(level) == 1:
|
||||
page.typeset = anchors[id].typeset
|
||||
page.title = re.sub(r"<[^>]+>", "", title)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Flatten a tree of anchors
|
||||
def _flatten(items):
|
||||
anchors = {}
|
||||
for item in items:
|
||||
anchors[item.id] = item
|
||||
|
||||
# Recursively expand children
|
||||
if item.children:
|
||||
anchors.update(_flatten(item.children))
|
||||
|
||||
# Return anchors
|
||||
return anchors
|
||||
@@ -21,31 +21,22 @@
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules
|
||||
// Variables: breakpoints
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Content area
|
||||
.md-content {
|
||||
|
||||
// Content wrapper
|
||||
&__inner {
|
||||
padding: px2rem(104px) 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
// Remove unnecessary spacing
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Section header
|
||||
header {
|
||||
display: block;
|
||||
transition: opacity 750ms;
|
||||
|
||||
// Section header is hidden
|
||||
.js &[hidden] {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Device-specific breakpoints
|
||||
$break-devices: (
|
||||
mobile: (
|
||||
portrait: px2em(220px) px2em(479.75px),
|
||||
landscape: px2em(480px) px2em(719.75px)
|
||||
),
|
||||
tablet: (
|
||||
portrait: px2em(720px) px2em(959.75px),
|
||||
landscape: px2em(960px) px2em(1219.75px)
|
||||
),
|
||||
screen: (
|
||||
small: px2em(1220px) px2em(1599.75px),
|
||||
medium: px2em(1600px) px2em(1999.75px),
|
||||
large: px2em(2000px)
|
||||
)
|
||||
);
|
||||
@@ -1,323 +0,0 @@
|
||||
////
|
||||
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
///
|
||||
/// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
/// copy of this software and associated documentation files (the "Software"),
|
||||
/// to deal in the Software without restriction, including without limitation
|
||||
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
/// and/or sell copies of the Software, and to permit persons to whom the
|
||||
/// Software is furnished to do so, subject to the following conditions:
|
||||
///
|
||||
/// The above copyright notice and this permission notice shall be included in
|
||||
/// all copies or substantial portions of the Software.
|
||||
///
|
||||
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
/// DEALINGS
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Keyframes
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Pumping heart animation
|
||||
@keyframes heart {
|
||||
0%,
|
||||
40%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
20%,
|
||||
60% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
// New animation
|
||||
@keyframes new {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.15) rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Scoped in typesetted content to match specificity of regular content
|
||||
.md-typeset {
|
||||
|
||||
// Twitter icon
|
||||
.twitter {
|
||||
color: #eeeeee;
|
||||
}
|
||||
|
||||
// Mastodon icon - it's not the exact brand color, because that doesn't work
|
||||
// well on dark backgrounds, so we lightened it up a bit.
|
||||
.mastodon {
|
||||
color: #897ff8;
|
||||
}
|
||||
|
||||
// Bluesky icon
|
||||
.bluesky {
|
||||
color: #0285ff;
|
||||
}
|
||||
|
||||
// Insiders video
|
||||
.mdx-video {
|
||||
width: auto;
|
||||
|
||||
// Insiders video container
|
||||
&__inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: 56.138%;
|
||||
}
|
||||
|
||||
// Insiders video iframe
|
||||
iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Pumping heart
|
||||
.mdx-heart {
|
||||
animation: heart 1000ms infinite;
|
||||
}
|
||||
|
||||
// New pulse
|
||||
.mdx-pulse {
|
||||
animation: new 2000ms infinite;
|
||||
|
||||
// Actual icon
|
||||
svg {
|
||||
fill: var(--md-accent-fg-color);
|
||||
}
|
||||
}
|
||||
|
||||
// Insiders color (for links, etc.)
|
||||
.mdx-insiders {
|
||||
color: $clr-pink-500;
|
||||
}
|
||||
|
||||
// BETA #####################################################################
|
||||
|
||||
// Badge
|
||||
.mdx-badge {
|
||||
font-size: 0.85em;
|
||||
|
||||
// Badge with heart
|
||||
&--heart {
|
||||
--md-typeset-a-color: hsla(#{hex2hsl($clr-pink-500)}, 1);
|
||||
--md-accent-fg-color: hsla(#{hex2hsl($clr-pink-a200)}, 1);
|
||||
--md-accent-fg-color--transparent: hsla(#{hex2hsl($clr-pink-500)}, 0.1);
|
||||
|
||||
color: $clr-pink-500;
|
||||
|
||||
// Animate icon
|
||||
.twemoji {
|
||||
animation: heart 1000ms infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// Badge moved to the right
|
||||
&--right {
|
||||
float: right;
|
||||
margin-left: 0.35em;
|
||||
}
|
||||
|
||||
// Badge icon
|
||||
&__icon {
|
||||
padding: px2rem(4px);
|
||||
background: var(--md-accent-fg-color--transparent);
|
||||
border-start-start-radius: px2rem(2px);
|
||||
border-end-start-radius: px2rem(2px);
|
||||
|
||||
// If icon is alone, round corners
|
||||
&:last-child {
|
||||
border-radius: px2rem(2px);
|
||||
}
|
||||
}
|
||||
|
||||
// Badge text
|
||||
&__text {
|
||||
padding: px2rem(4px) px2rem(6px);
|
||||
border-start-end-radius: px2rem(2px);
|
||||
border-end-end-radius: px2rem(2px);
|
||||
box-shadow: 0 0 0 1px inset var(--md-accent-fg-color--transparent);
|
||||
}
|
||||
}
|
||||
|
||||
// BETA #####################################################################
|
||||
|
||||
// Switch buttons
|
||||
.mdx-switch button {
|
||||
cursor: pointer;
|
||||
transition: opacity 250ms;
|
||||
|
||||
// Button on focus/hover
|
||||
&:is(:focus, :hover) {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
// Code block
|
||||
> code {
|
||||
display: block;
|
||||
color: var(--md-primary-bg-color);
|
||||
background-color: var(--md-primary-fg-color);
|
||||
}
|
||||
}
|
||||
|
||||
// Two-column layout
|
||||
.mdx-columns {
|
||||
|
||||
// Column
|
||||
ol,
|
||||
ul {
|
||||
columns: 2;
|
||||
|
||||
// [mobile portrait -]: Reset columns on mobile
|
||||
@include break-to-device(mobile portrait) {
|
||||
columns: initial;
|
||||
}
|
||||
}
|
||||
|
||||
// Column item
|
||||
li {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
// Language list
|
||||
.mdx-flags {
|
||||
margin: 2em auto;
|
||||
|
||||
// Language list
|
||||
ol {
|
||||
list-style: none;
|
||||
|
||||
// Language list item
|
||||
li {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
// Language item
|
||||
&__item {
|
||||
display: flex;
|
||||
gap: px2rem(12px);
|
||||
}
|
||||
|
||||
// Language content
|
||||
&__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
|
||||
// Language name
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
// Language link
|
||||
> span:nth-child(2) {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
// Language code
|
||||
code {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Social card
|
||||
.mdx-social {
|
||||
position: relative;
|
||||
height: min(#{px2rem(540px)}, 80vw);
|
||||
|
||||
// Social card image on hover
|
||||
&:hover .mdx-social__image {
|
||||
background-color: rgba(228, 228, 228, 0.05);
|
||||
}
|
||||
|
||||
// Social card layer
|
||||
&__layer {
|
||||
position: absolute;
|
||||
margin-top: px2rem(80px);
|
||||
transition: 250ms cubic-bezier(0.7, 0, 0.3, 1);
|
||||
transform-style: preserve-3d;
|
||||
|
||||
// Social card layer on hover
|
||||
&:hover {
|
||||
|
||||
// Social card label
|
||||
.mdx-social__label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Social card image
|
||||
.mdx-social__image {
|
||||
background-color: rgba(127, 127, 127, 0.99);
|
||||
}
|
||||
|
||||
// Hide top layers
|
||||
~ .mdx-social__layer {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Social card image
|
||||
&__image {
|
||||
box-shadow:
|
||||
px2rem(-5px) px2rem(5px) px2rem(10px)
|
||||
rgba(0, 0, 0, 0.05);
|
||||
transition: all 250ms;
|
||||
transform: rotate(-40deg) skew(15deg, 15deg) scale(0.7);
|
||||
|
||||
// Actual image
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// Social card label
|
||||
&__label {
|
||||
position: absolute;
|
||||
display: block;
|
||||
padding: px2rem(4px) px2rem(8px);
|
||||
color: var(--md-default-bg-color);
|
||||
background-color: var(--md-default-fg-color--light);
|
||||
opacity: 0;
|
||||
transition: all 250ms;
|
||||
}
|
||||
|
||||
// Transform on hover
|
||||
@for $i from 6 through 0 {
|
||||
&:hover .mdx-social__layer:nth-child(#{$i}) {
|
||||
transform: translateY(#{($i - 3) * -10}px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
////
|
||||
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
///
|
||||
/// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
/// copy of this software and associated documentation files (the "Software"),
|
||||
/// to deal in the Software without restriction, including without limitation
|
||||
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
/// and/or sell copies of the Software, and to permit persons to whom the
|
||||
/// Software is furnished to do so, subject to the following conditions:
|
||||
///
|
||||
/// The above copyright notice and this permission notice shall be included in
|
||||
/// all copies or substantial portions of the Software.
|
||||
///
|
||||
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
/// DEALINGS
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Landing page container
|
||||
.mdx-container {
|
||||
padding-top: px2rem(20px);
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1123 258'><path d='M1124,2c0,0 0,256 0,256l-1125,0l0,-48c0,0 16,5 55,5c116,0 197,-92 325,-92c121,0 114,46 254,46c140,0 214,-167 572,-166Z' style='fill: hsla(0, 0%, 100%, 1)' /></svg>") no-repeat bottom,
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
var(--md-primary-fg-color),
|
||||
hsla(280, 67%, 55%, 1) 99%,
|
||||
var(--md-default-bg-color) 99%
|
||||
);
|
||||
|
||||
// Adjust background for slate theme
|
||||
[data-md-color-scheme="slate"] & {
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1123 258'><path d='M1124,2c0,0 0,256 0,256l-1125,0l0,-48c0,0 16,5 55,5c116,0 197,-92 325,-92c121,0 114,46 254,46c140,0 214,-167 572,-166Z' style='fill: hsla(230, 15%, 14%, 1)' /></svg>") no-repeat bottom,
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
var(--md-primary-fg-color),
|
||||
hsla(230, 15%, 25%, 1) 99%,
|
||||
var(--md-default-bg-color) 99%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Landing page hero
|
||||
.mdx-hero {
|
||||
margin: 0 px2rem(16px);
|
||||
color: var(--md-primary-bg-color);
|
||||
|
||||
// Hero headline
|
||||
h1 {
|
||||
margin-bottom: px2rem(20px);
|
||||
font-weight: 700;
|
||||
color: currentcolor;
|
||||
|
||||
// [mobile portrait -]: Larger hero headline
|
||||
@include break-to-device(mobile portrait) {
|
||||
font-size: px2rem(28px);
|
||||
}
|
||||
}
|
||||
|
||||
// Hero content
|
||||
&__content {
|
||||
padding-bottom: px2rem(120px);
|
||||
}
|
||||
|
||||
// [tablet landscape +]: Columnar display
|
||||
@include break-from-device(tablet landscape) {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
// Adjust spacing and set dimensions
|
||||
&__content {
|
||||
max-width: px2rem(380px);
|
||||
padding-bottom: 14vw;
|
||||
margin-top: px2rem(70px);
|
||||
}
|
||||
|
||||
// Hero image
|
||||
&__image {
|
||||
order: 1;
|
||||
width: px2rem(760px);
|
||||
transform: translateX(#{px2rem(80px)});
|
||||
}
|
||||
}
|
||||
|
||||
// [screen +]: Columnar display and adjusted spacing
|
||||
@include break-from-device(screen) {
|
||||
|
||||
// Hero image
|
||||
&__image {
|
||||
transform: translateX(#{px2rem(160px)});
|
||||
}
|
||||
}
|
||||
|
||||
// Button
|
||||
.md-button {
|
||||
margin-top: px2rem(10px);
|
||||
margin-right: px2rem(10px);
|
||||
color: var(--md-primary-bg-color);
|
||||
|
||||
// Button on focus/hover
|
||||
&:is(:focus, :hover) {
|
||||
color: var(--md-accent-bg-color);
|
||||
background-color: var(--md-accent-fg-color);
|
||||
border-color: var(--md-accent-fg-color);
|
||||
}
|
||||
|
||||
// Primary button
|
||||
&--primary {
|
||||
color: hsla(280, 37%, 48%, 1);
|
||||
background-color: var(--md-primary-bg-color);
|
||||
border-color: var(--md-primary-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
////
|
||||
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
///
|
||||
/// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
/// copy of this software and associated documentation files (the "Software"),
|
||||
/// to deal in the Software without restriction, including without limitation
|
||||
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
/// and/or sell copies of the Software, and to permit persons to whom the
|
||||
/// Software is furnished to do so, subject to the following conditions:
|
||||
///
|
||||
/// The above copyright notice and this permission notice shall be included in
|
||||
/// all copies or substantial portions of the Software.
|
||||
///
|
||||
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
/// DEALINGS
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Scoped in typesetted content to match specificity of regular content
|
||||
.md-typeset {
|
||||
|
||||
// Icon search
|
||||
.mdx-iconsearch {
|
||||
position: relative;
|
||||
background-color: var(--md-default-bg-color);
|
||||
border-radius: px2rem(2px);
|
||||
box-shadow: var(--md-shadow-z1);
|
||||
transition: box-shadow 125ms;
|
||||
|
||||
// Icon search on focus/hover
|
||||
&:is(:focus-within, :hover) {
|
||||
box-shadow: var(--md-shadow-z2);
|
||||
}
|
||||
|
||||
// Icon search input
|
||||
.md-input {
|
||||
background: var(--md-default-bg-color);
|
||||
box-shadow: none;
|
||||
|
||||
// Slate theme, i.e. dark mode
|
||||
[data-md-color-scheme="slate"] & {
|
||||
background: var(--md-code-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Icon search result
|
||||
.mdx-iconsearch-result {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
// Hack: promote to own layer to reduce jitter
|
||||
backface-visibility: hidden;
|
||||
touch-action: pan-y;
|
||||
scrollbar-color: var(--md-default-fg-color--lighter) transparent;
|
||||
scrollbar-width: thin;
|
||||
|
||||
// Icon search result inside tooltip
|
||||
.md-tooltip & {
|
||||
max-height: px2rem(205px);
|
||||
}
|
||||
|
||||
// Webkit scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: px2rem(4px);
|
||||
height: px2rem(4px);
|
||||
}
|
||||
|
||||
// Webkit scrollbar thumb
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--md-default-fg-color--lighter);
|
||||
|
||||
// Webkit scrollbar thumb on hover
|
||||
&:hover {
|
||||
background-color: var(--md-accent-fg-color);
|
||||
}
|
||||
}
|
||||
|
||||
// Icon search result metadata
|
||||
&__meta {
|
||||
position: absolute;
|
||||
top: px2rem(8px);
|
||||
right: px2rem(12px);
|
||||
font-size: px2rem(12.8px);
|
||||
color: var(--md-default-fg-color--lighter);
|
||||
|
||||
// [mobile portrait -]: Hide meta
|
||||
@include break-to-device(mobile portrait) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Icon search result select
|
||||
&__select {
|
||||
position: absolute;
|
||||
top: px2rem(8px);
|
||||
right: px2rem(12px);
|
||||
padding-block: 0.15em;
|
||||
font-size: px2rem(12.8px);
|
||||
color: var(--md-default-fg-color--light);
|
||||
background-color: var(--md-default-fg-color--lightest);
|
||||
border: none;
|
||||
border-radius: px2rem(2px);
|
||||
transition: color 125ms, background-color 125ms;
|
||||
|
||||
// Focused or hovered
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: var(--md-accent-bg-color);
|
||||
background-color: var(--md-accent-fg-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// Adjust spacing
|
||||
+ .mdx-iconsearch-result__meta {
|
||||
right: px2rem(82px);
|
||||
}
|
||||
}
|
||||
|
||||
// Icon search result list
|
||||
&__list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
// Hack: necessary because of increased specificity due to the PostCSS
|
||||
// plugin which prefixes this with `[dir=...]` selectors.
|
||||
margin-inline-start: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
// Icon search result item
|
||||
&__item {
|
||||
padding: px2rem(4px) px2rem(12px);
|
||||
margin: 0;
|
||||
// Hack: necessary because of increased specificity due to the PostCSS
|
||||
// plugin which prefixes this with `[dir=...]` selectors.
|
||||
margin-inline-start: 0;
|
||||
border-bottom: px2rem(1px) solid var(--md-default-fg-color--lightest);
|
||||
|
||||
// Omit border on last child
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
// Item content
|
||||
> * {
|
||||
margin-right: px2rem(12px);
|
||||
}
|
||||
|
||||
// Set icon dimensions to fit
|
||||
img {
|
||||
width: px2rem(18px);
|
||||
height: px2rem(18px);
|
||||
|
||||
// Slate theme, i.e. dark mode
|
||||
[data-md-color-scheme="slate"] &[src*="squidfunk"] {
|
||||
filter: invert(1); /* stylelint-disable-line */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
////
|
||||
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
///
|
||||
/// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
/// copy of this software and associated documentation files (the "Software"),
|
||||
/// to deal in the Software without restriction, including without limitation
|
||||
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
/// and/or sell copies of the Software, and to permit persons to whom the
|
||||
/// Software is furnished to do so, subject to the following conditions:
|
||||
///
|
||||
/// The above copyright notice and this permission notice shall be included in
|
||||
/// all copies or substantial portions of the Software.
|
||||
///
|
||||
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
/// DEALINGS
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Scoped in typesetted content to match specificity of regular content
|
||||
.md-typeset {
|
||||
|
||||
// Premium sponsors
|
||||
.mdx-premium {
|
||||
|
||||
// Paragraphs
|
||||
p {
|
||||
margin: 2em 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Premium sponsor image
|
||||
img {
|
||||
height: px2rem(65px);
|
||||
}
|
||||
|
||||
// Premium sponsor list
|
||||
p:last-child {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
// Premium sponsor link
|
||||
> a {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sponsorship
|
||||
.mdx-sponsorship {
|
||||
|
||||
// Sponsorship list
|
||||
&__list {
|
||||
margin: 2em 0;
|
||||
|
||||
// Clearfix, because we can't use overflow: auto
|
||||
&::after {
|
||||
display: block;
|
||||
clear: both;
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
// Sponsorship item
|
||||
&__item {
|
||||
display: block;
|
||||
float: inline-start;
|
||||
width: px2rem(32px);
|
||||
height: px2rem(32px);
|
||||
margin: px2rem(4px);
|
||||
overflow: hidden;
|
||||
border-radius: 100%;
|
||||
transition:
|
||||
color 125ms,
|
||||
transform 125ms;
|
||||
transform: scale(1);
|
||||
|
||||
// Sponsor item on focus/hover
|
||||
&:is(:focus, :hover) {
|
||||
transform: scale(1.1);
|
||||
|
||||
// Sponsor avatar
|
||||
img {
|
||||
filter: grayscale(0%);
|
||||
}
|
||||
}
|
||||
|
||||
// Private sponsor
|
||||
&--private {
|
||||
font-size: px2rem(12px);
|
||||
font-weight: 700;
|
||||
line-height: px2rem(32px);
|
||||
color: var(--md-default-fg-color--lighter);
|
||||
text-align: center;
|
||||
background: var(--md-default-fg-color--lightest);
|
||||
}
|
||||
|
||||
// Sponsor avatar
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
filter: grayscale(100%) opacity(75%);
|
||||
transition: filter 125ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sponsorship button
|
||||
.mdx-sponsorship-button {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
// Sponsorship count and total
|
||||
.mdx-sponsorship-count,
|
||||
.mdx-sponsorship-total {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
////
|
||||
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
///
|
||||
/// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
/// copy of this software and associated documentation files (the "Software"),
|
||||
/// to deal in the Software without restriction, including without limitation
|
||||
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
/// and/or sell copies of the Software, and to permit persons to whom the
|
||||
/// Software is furnished to do so, subject to the following conditions:
|
||||
///
|
||||
/// The above copyright notice and this permission notice shall be included in
|
||||
/// all copies or substantial portions of the Software.
|
||||
///
|
||||
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
/// DEALINGS
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Parallax variables
|
||||
:root {
|
||||
--md-parallax-perspective: #{px2rem(50px)};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Parallax
|
||||
.mdx-parallax {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin-top: px2rem(-48px);
|
||||
overflow: hidden auto;
|
||||
overscroll-behavior-y: none;
|
||||
perspective: var(--md-parallax-perspective);
|
||||
scroll-behavior: smooth;
|
||||
|
||||
// Parallax group
|
||||
&__group {
|
||||
position: relative;
|
||||
display: block;
|
||||
color: var(--md-typeset-color);
|
||||
background-color: var(--md-default-bg-color);
|
||||
transform-style: preserve-3d;
|
||||
|
||||
// First parallax group which contains the hero
|
||||
&:first-child {
|
||||
height: 140vh;
|
||||
background-color: transparent;
|
||||
|
||||
// Hack: setting `contain` reduces pressure on the GPU, as it mitigates
|
||||
// a bug in WebKit where the scale is not accounted for when calculating
|
||||
// element size, as noted in https://bit.ly/3LRC25r. Unfortunately, we
|
||||
// can't use the technique laid out in this StackOverflow answer, because
|
||||
// using the bottom as perspective origin totally messes up positioning,
|
||||
// but setting `contain` seems to solve the issue at hand.
|
||||
contain: strict;
|
||||
|
||||
// Of course, how could it be otherwise, Safari doesn't properly support
|
||||
// this property, so we must disable it via JavaScript. Browsers, eh?
|
||||
.safari & {
|
||||
contain: none;
|
||||
}
|
||||
|
||||
// This is a hack for Firefox to avoid the results of an unfixable error
|
||||
// with the parallax effect in combination with `contain: strict`.
|
||||
.ff-hack & {
|
||||
contain: initial !important; // stylelint-disable-line
|
||||
}
|
||||
|
||||
// Hack: we can't use `vw` and `vh` in division, but we have to ensure a
|
||||
// correct aspect ratio for the wrapper. Don't mind the magic numbers.
|
||||
@for $i from 0 through 6 {
|
||||
@media (min-width: #{125 + 12.5 * $i}vh) {
|
||||
height: #{120 + 5 * $i}vw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last parallax group contains footer
|
||||
&:last-child {
|
||||
background-color: var(--md-default-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
// Parallax layer
|
||||
&__layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: calc(10 - var(--md-parallax-depth, 0));
|
||||
width: 100vw;
|
||||
height: max(120vh, 100vw);
|
||||
pointer-events: none;
|
||||
transform:
|
||||
translateZ(
|
||||
calc(
|
||||
var(--md-parallax-perspective) *
|
||||
var(--md-parallax-depth) * -1
|
||||
)
|
||||
)
|
||||
scale(
|
||||
calc(
|
||||
var(--md-parallax-depth) + 1
|
||||
)
|
||||
);
|
||||
transform-origin: 50vw 50vh;
|
||||
}
|
||||
|
||||
// Parallax layer: image - we use `object-fit` and `object-position` so we
|
||||
// can use responsive imagery, since CSS `srcset` and `sizes` support is meh.
|
||||
&__image {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: var(--md-image-position, 50%);
|
||||
}
|
||||
|
||||
// Parallax layer: gradient
|
||||
&__blend {
|
||||
top: initial;
|
||||
bottom: 0;
|
||||
height: min(100vh, 100vw);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
var(--md-default-bg-color)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
////
|
||||
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
///
|
||||
/// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
/// copy of this software and associated documentation files (the "Software"),
|
||||
/// to deal in the Software without restriction, including without limitation
|
||||
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
/// and/or sell copies of the Software, and to permit persons to whom the
|
||||
/// Software is furnished to do so, subject to the following conditions:
|
||||
///
|
||||
/// The above copyright notice and this permission notice shall be included in
|
||||
/// all copies or substantial portions of the Software.
|
||||
///
|
||||
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
/// DEALINGS
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Keyframes
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Animate scroll-down arrow
|
||||
@keyframes bounce {
|
||||
0% {
|
||||
transform: translateY(0) translateZ(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-16px) translateZ(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Hero
|
||||
.mdx-hero {
|
||||
display: block;
|
||||
height: inherit;
|
||||
|
||||
// Hero is hidden
|
||||
.js &[hidden] > * {
|
||||
opacity: 0;
|
||||
transition:
|
||||
transform 0ms 100ms,
|
||||
opacity 100ms;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
|
||||
// Hero scroll wrapper
|
||||
&__scrollwrap {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 9;
|
||||
height: 100vh;
|
||||
// Hack: add bottom margin, so the element stays in place, even though the
|
||||
// bottom of the container is reached, as it disappears behind the gradient
|
||||
margin-bottom: -100vh;
|
||||
}
|
||||
|
||||
// Hero wrapper
|
||||
&__inner {
|
||||
position: absolute;
|
||||
bottom: px2rem(64px);
|
||||
display: block;
|
||||
width: 100%;
|
||||
transition:
|
||||
transform 400ms cubic-bezier(0.1, 0.7, 0.1, 1),
|
||||
opacity 250ms;
|
||||
|
||||
// [mobile -]: Adjust spacing
|
||||
@include break-to-device(mobile) {
|
||||
bottom: px2rem(128px);
|
||||
}
|
||||
}
|
||||
|
||||
// Hero teaser
|
||||
&__teaser {
|
||||
max-width: px2rem(540px);
|
||||
margin: 0 px2rem(16px);
|
||||
color: var(--md-primary-bg-color);
|
||||
// Hack: promote to own layer to reduce jitter
|
||||
backface-visibility: hidden;
|
||||
|
||||
// Hero headline
|
||||
h1 {
|
||||
margin-bottom: 0;
|
||||
font-weight: 700;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
// Hack: set text shadow to improve readability of white text against the
|
||||
// images but skip the buttons as text shadow is not very Materialy
|
||||
:not(.md-button) {
|
||||
text-shadow: 0 0 px2rem(4px) rgba(33, 29, 45, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
// Hero attribution link
|
||||
& &__attribution {
|
||||
position: absolute;
|
||||
right: px2rem(16px);
|
||||
bottom: px2rem(-48px);
|
||||
padding: px2rem(2px) px2rem(8px);
|
||||
font-size: px2rem(10px);
|
||||
color: var(--md-default-fg-color);
|
||||
background-color: var(--md-default-bg-color--light);
|
||||
border-radius: px2rem(2px);
|
||||
transition:
|
||||
color 125ms,
|
||||
background-color 125ms;
|
||||
|
||||
// Link on focus/hover
|
||||
&:is(:focus, :hover) {
|
||||
color: var(--md-accent-bg-color);
|
||||
background-color: var(--md-accent-fg-color);
|
||||
}
|
||||
}
|
||||
|
||||
// Hero more indicator
|
||||
&__more {
|
||||
position: absolute;
|
||||
bottom: px2rem(-48px);
|
||||
left: 50%;
|
||||
display: block;
|
||||
margin-left: px2rem(-12px);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
animation: bounce 2000ms infinite cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
// More indicator icon
|
||||
svg {
|
||||
width: px2rem(24px);
|
||||
height: px2rem(24px);
|
||||
fill: rgb(255, 255, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
////
|
||||
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
///
|
||||
/// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
/// copy of this software and associated documentation files (the "Software"),
|
||||
/// to deal in the Software without restriction, including without limitation
|
||||
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
/// and/or sell copies of the Software, and to permit persons to whom the
|
||||
/// Software is furnished to do so, subject to the following conditions:
|
||||
///
|
||||
/// The above copyright notice and this permission notice shall be included in
|
||||
/// all copies or substantial portions of the Software.
|
||||
///
|
||||
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
/// DEALINGS
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// More than just a static site
|
||||
.mdx-spotlight {
|
||||
margin: 2em 0;
|
||||
|
||||
// Spotlight feature
|
||||
&__feature {
|
||||
display: flex;
|
||||
flex: 1 0 48%;
|
||||
flex-flow: row nowrap;
|
||||
gap: px2rem(64px);
|
||||
margin: 0;
|
||||
margin-bottom: px2rem(64px);
|
||||
|
||||
// [tablet portrait -]: Adjust layout and spacing
|
||||
@include break-to-device(tablet portrait) {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
// [tablet landscape +]: Reverse direction for every other item
|
||||
@include break-from-device(tablet landscape) {
|
||||
|
||||
// Reverse direction for every other item
|
||||
&:nth-child(2n + 1) {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust spacing on last child
|
||||
&:last-child {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
// Spotlight feature image link
|
||||
> a {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
transition: transform 500ms cubic-bezier(0.075, 0.85, 0.175, 1);
|
||||
|
||||
// [tablet portrait -]: Adjust spacing
|
||||
@include break-to-device(tablet portrait) {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
// Image link on hover
|
||||
&:hover {
|
||||
transform: scale(1.025);
|
||||
}
|
||||
}
|
||||
|
||||
// Spotlight feature image
|
||||
a > img {
|
||||
display: block;
|
||||
width: px2rem(500px);
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: px2rem(4px);
|
||||
box-shadow: var(--md-shadow-z2);
|
||||
transition:
|
||||
transform 750ms 125ms cubic-bezier(0.075, 0.85, 0.175, 1),
|
||||
opacity 750ms 125ms;
|
||||
}
|
||||
|
||||
// Spotlight feature description
|
||||
figcaption {
|
||||
margin-top: px2rem(16px);
|
||||
transition:
|
||||
transform 750ms 125ms cubic-bezier(0.075, 0.85, 0.175, 1),
|
||||
opacity 750ms 125ms;
|
||||
}
|
||||
|
||||
// Spotlight feature is hidden
|
||||
.js &[hidden] {
|
||||
|
||||
// Spotlight feature image link
|
||||
> a > img {
|
||||
opacity: 0;
|
||||
transform: translateY(px2rem(32px));
|
||||
}
|
||||
|
||||
// Spotlight feature description
|
||||
> figcaption {
|
||||
opacity: 0;
|
||||
transform: translateX(px2rem(32px));
|
||||
}
|
||||
|
||||
// Reverse direction for every other feature
|
||||
&:nth-child(2n) > figcaption {
|
||||
transform: translateX(px2rem(-32px));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
////
|
||||
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
///
|
||||
/// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
/// copy of this software and associated documentation files (the "Software"),
|
||||
/// to deal in the Software without restriction, including without limitation
|
||||
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
/// and/or sell copies of the Software, and to permit persons to whom the
|
||||
/// Software is furnished to do so, subject to the following conditions:
|
||||
///
|
||||
/// The above copyright notice and this permission notice shall be included in
|
||||
/// all copies or substantial portions of the Software.
|
||||
///
|
||||
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
/// DEALINGS
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// What our users say
|
||||
.mdx-users {
|
||||
display: flex;
|
||||
gap: px2rem(64px);
|
||||
margin: px2rem(48px) 0;
|
||||
|
||||
// [tablet portrait -]: Adjust layout
|
||||
@include break-to-device(tablet portrait) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// Testminonial
|
||||
&__testimonial {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: px2rem(24px);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
|
||||
// Delay transitions by a small amount
|
||||
@for $i from 1 through 3 {
|
||||
&:nth-child(#{$i}) {
|
||||
transition-delay: 125ms + 75ms * $i;
|
||||
}
|
||||
}
|
||||
|
||||
// Testimonial image
|
||||
img {
|
||||
width: px2rem(200px);
|
||||
height: auto;
|
||||
margin-inline: auto;
|
||||
border-radius: px2rem(100px);
|
||||
transition:
|
||||
transform 750ms cubic-bezier(0.075, 0.85, 0.175, 1),
|
||||
opacity 750ms;
|
||||
transition-delay: inherit;
|
||||
}
|
||||
|
||||
// Testimonial content
|
||||
figcaption {
|
||||
display: block;
|
||||
transition:
|
||||
transform 750ms cubic-bezier(0.075, 0.85, 0.175, 1),
|
||||
opacity 750ms;
|
||||
transition-delay: inherit;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
hr {
|
||||
width: px2rem(100px);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
// Testimonial quote
|
||||
cite {
|
||||
display: block;
|
||||
hyphens: auto;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
// Testimonial is hidden
|
||||
.js &[hidden] {
|
||||
|
||||
// Testimonial image
|
||||
img {
|
||||
opacity: 0;
|
||||
transform: scale(0.75);
|
||||
}
|
||||
|
||||
// Testimonial content
|
||||
figcaption {
|
||||
opacity: 0;
|
||||
transform: translateY(px2rem(32px));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
src/templates/assets/stylesheets/main.scss
Normal file
91
src/templates/assets/stylesheets/main.scss
Normal file
@@ -0,0 +1,91 @@
|
||||
////
|
||||
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
///
|
||||
/// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
/// copy of this software and associated documentation files (the "Software"),
|
||||
/// to deal in the Software without restriction, including without limitation
|
||||
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
/// and/or sell copies of the Software, and to permit persons to whom the
|
||||
/// Software is furnished to do so, subject to the following conditions:
|
||||
///
|
||||
/// The above copyright notice and this permission notice shall be included in
|
||||
/// all copies or substantial portions of the Software.
|
||||
///
|
||||
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
/// DEALINGS
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Dependencies
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@import "material-color";
|
||||
@import "material-shadows";
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Local imports
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@import "utilities/break";
|
||||
@import "utilities/convert";
|
||||
|
||||
@import "config";
|
||||
|
||||
@import "main/resets";
|
||||
@import "main/colors";
|
||||
@import "main/icons";
|
||||
@import "main/typeset";
|
||||
|
||||
@import "main/components/author";
|
||||
@import "main/components/banner";
|
||||
@import "main/components/base";
|
||||
@import "main/components/clipboard";
|
||||
@import "main/components/code";
|
||||
@import "main/components/consent";
|
||||
@import "main/components/content";
|
||||
@import "main/components/dialog";
|
||||
@import "main/components/feedback";
|
||||
@import "main/components/footer";
|
||||
@import "main/components/form";
|
||||
@import "main/components/header";
|
||||
@import "main/components/meta";
|
||||
@import "main/components/nav";
|
||||
@import "main/components/pagination";
|
||||
@import "main/components/path";
|
||||
@import "main/components/post";
|
||||
@import "main/components/progress";
|
||||
@import "main/components/search";
|
||||
@import "main/components/select";
|
||||
@import "main/components/sidebar";
|
||||
@import "main/components/source";
|
||||
@import "main/components/status";
|
||||
@import "main/components/tabs";
|
||||
@import "main/components/tag";
|
||||
@import "main/components/tooltip";
|
||||
@import "main/components/tooltip2";
|
||||
@import "main/components/top";
|
||||
@import "main/components/version";
|
||||
|
||||
@import "main/extensions/markdown/admonition";
|
||||
@import "main/extensions/markdown/footnotes";
|
||||
@import "main/extensions/markdown/toc";
|
||||
|
||||
@import "main/extensions/pymdownx/arithmatex";
|
||||
@import "main/extensions/pymdownx/critic";
|
||||
@import "main/extensions/pymdownx/details";
|
||||
@import "main/extensions/pymdownx/emoji";
|
||||
@import "main/extensions/pymdownx/highlight";
|
||||
@import "main/extensions/pymdownx/keys";
|
||||
@import "main/extensions/pymdownx/tabbed";
|
||||
@import "main/extensions/pymdownx/tasklist";
|
||||
|
||||
@import "main/integrations/giscus";
|
||||
@import "main/integrations/mermaid";
|
||||
|
||||
@import "main/modifiers/grid";
|
||||
@import "main/modifiers/inline";
|
||||
157
src/templates/assets/stylesheets/main/_colors.scss
Normal file
157
src/templates/assets/stylesheets/main/_colors.scss
Normal file
@@ -0,0 +1,157 @@
|
||||
////
|
||||
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
///
|
||||
/// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
/// copy of this software and associated documentation files (the "Software"),
|
||||
/// to deal in the Software without restriction, including without limitation
|
||||
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
/// and/or sell copies of the Software, and to permit persons to whom the
|
||||
/// Software is furnished to do so, subject to the following conditions:
|
||||
///
|
||||
/// The above copyright notice and this permission notice shall be included in
|
||||
/// all copies or substantial portions of the Software.
|
||||
///
|
||||
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
/// DEALINGS
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Color variables
|
||||
:root {
|
||||
@extend %root;
|
||||
|
||||
// Primary color shades
|
||||
--md-primary-fg-color: hsla(#{hex2hsl($clr-indigo-500)}, 1);
|
||||
--md-primary-fg-color--light: hsla(#{hex2hsl($clr-indigo-400)}, 1);
|
||||
--md-primary-fg-color--dark: hsla(#{hex2hsl($clr-indigo-700)}, 1);
|
||||
--md-primary-bg-color: hsla(0, 0%, 100%, 1);
|
||||
--md-primary-bg-color--light: hsla(0, 0%, 100%, 0.7);
|
||||
|
||||
// Accent color shades
|
||||
--md-accent-fg-color: hsla(#{hex2hsl($clr-indigo-a200)}, 1);
|
||||
--md-accent-fg-color--transparent: hsla(#{hex2hsl($clr-indigo-a200)}, 0.1);
|
||||
--md-accent-bg-color: hsla(0, 0%, 100%, 1);
|
||||
--md-accent-bg-color--light: hsla(0, 0%, 100%, 0.7);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Allow to explicitly use color schemes in nested content
|
||||
[data-md-color-scheme="default"] {
|
||||
@extend %root;
|
||||
|
||||
// Indicate that the site is rendered with a light color scheme
|
||||
color-scheme: light;
|
||||
|
||||
// Hide images for dark mode
|
||||
img[src$="#only-dark"],
|
||||
img[src$="#gh-dark-mode-only"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Placeholders
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Default theme, i.e. light mode
|
||||
%root {
|
||||
|
||||
// Color hue in the range [0,360] - change this variable to alter the tone
|
||||
// of the theme, e.g. to make it more redish or greenish
|
||||
--md-hue: 225deg;
|
||||
|
||||
// Default color shades
|
||||
--md-default-fg-color: hsla(0, 0%, 0%, 0.87);
|
||||
--md-default-fg-color--light: hsla(0, 0%, 0%, 0.54);
|
||||
--md-default-fg-color--lighter: hsla(0, 0%, 0%, 0.32);
|
||||
--md-default-fg-color--lightest: hsla(0, 0%, 0%, 0.07);
|
||||
--md-default-bg-color: hsla(0, 0%, 100%, 1);
|
||||
--md-default-bg-color--light: hsla(0, 0%, 100%, 0.7);
|
||||
--md-default-bg-color--lighter: hsla(0, 0%, 100%, 0.3);
|
||||
--md-default-bg-color--lightest: hsla(0, 0%, 100%, 0.12);
|
||||
|
||||
// Code color shades
|
||||
--md-code-fg-color: hsla(200, 18%, 26%, 1);
|
||||
--md-code-bg-color: hsla(200, 0%, 96%, 1);
|
||||
--md-code-bg-color--light: hsla(200, 0%, 96%, 0.7);
|
||||
--md-code-bg-color--lighter: hsla(200, 0%, 96%, 0.3);
|
||||
|
||||
// Code highlighting color shades
|
||||
--md-code-hl-color: hsla(#{hex2hsl($clr-blue-a200)}, 1);
|
||||
--md-code-hl-color--light: hsla(#{hex2hsl($clr-blue-a200)}, 0.1);
|
||||
|
||||
// Code highlighting syntax color shades
|
||||
--md-code-hl-number-color: hsla(0, 67%, 50%, 1);
|
||||
--md-code-hl-special-color: hsla(340, 83%, 47%, 1);
|
||||
--md-code-hl-function-color: hsla(291, 45%, 50%, 1);
|
||||
--md-code-hl-constant-color: hsla(250, 63%, 60%, 1);
|
||||
--md-code-hl-keyword-color: hsla(219, 54%, 51%, 1);
|
||||
--md-code-hl-string-color: hsla(150, 63%, 30%, 1);
|
||||
--md-code-hl-name-color: var(--md-code-fg-color);
|
||||
--md-code-hl-operator-color: var(--md-default-fg-color--light);
|
||||
--md-code-hl-punctuation-color: var(--md-default-fg-color--light);
|
||||
--md-code-hl-comment-color: var(--md-default-fg-color--light);
|
||||
--md-code-hl-generic-color: var(--md-default-fg-color--light);
|
||||
--md-code-hl-variable-color: var(--md-default-fg-color--light);
|
||||
|
||||
// Typeset color shades
|
||||
--md-typeset-color: var(--md-default-fg-color);
|
||||
|
||||
// Typeset `a` color shades
|
||||
--md-typeset-a-color: var(--md-primary-fg-color);
|
||||
|
||||
// Typeset `del` and `ins` color shades
|
||||
--md-typeset-del-color: hsla(6, 90%, 60%, 0.15);
|
||||
--md-typeset-ins-color: hsla(150, 90%, 44%, 0.15);
|
||||
|
||||
// Typeset `kbd` color shades
|
||||
--md-typeset-kbd-color: hsla(0, 0%, 98%, 1);
|
||||
--md-typeset-kbd-accent-color: hsla(0, 100%, 100%, 1);
|
||||
--md-typeset-kbd-border-color: hsla(0, 0%, 72%, 1);
|
||||
|
||||
// Typeset `mark` color shades
|
||||
--md-typeset-mark-color: hsla(#{hex2hsl($clr-yellow-a200)}, 0.5);
|
||||
|
||||
// Typeset `table` color shades
|
||||
--md-typeset-table-color: hsla(0, 0%, 0%, 0.12);
|
||||
--md-typeset-table-color--light: hsla(0, 0%, 0%, 0.035);
|
||||
|
||||
// Admonition color shades
|
||||
--md-admonition-fg-color: var(--md-default-fg-color);
|
||||
--md-admonition-bg-color: var(--md-default-bg-color);
|
||||
|
||||
// Warning color shades
|
||||
--md-warning-fg-color: hsla(0, 0%, 0%, 0.87);
|
||||
--md-warning-bg-color: hsla(60, 100%, 80%, 1);
|
||||
|
||||
// Footer color shades
|
||||
--md-footer-fg-color: hsla(0, 0%, 100%, 1);
|
||||
--md-footer-fg-color--light: hsla(0, 0%, 100%, 0.7);
|
||||
--md-footer-fg-color--lighter: hsla(0, 0%, 100%, 0.45);
|
||||
--md-footer-bg-color: hsla(0, 0%, 0%, 0.87);
|
||||
--md-footer-bg-color--dark: hsla(0, 0%, 0%, 0.32);
|
||||
|
||||
// Shadow depth 1
|
||||
--md-shadow-z1:
|
||||
0 #{px2rem(4px)} #{px2rem(10px)} hsla(0, 0%, 0%, 0.05),
|
||||
0 0 #{px2rem(1px)} hsla(0, 0%, 0%, 0.1);
|
||||
|
||||
// Shadow depth 2
|
||||
--md-shadow-z2:
|
||||
0 #{px2rem(4px)} #{px2rem(10px)} hsla(0, 0%, 0%, 0.1),
|
||||
0 0 #{px2rem(1px)} hsla(0, 0%, 0%, 0.25);
|
||||
|
||||
// Shadow depth 3
|
||||
--md-shadow-z3:
|
||||
0 #{px2rem(4px)} #{px2rem(10px)} hsla(0, 0%, 0%, 0.2),
|
||||
0 0 #{px2rem(1px)} hsla(0, 0%, 0%, 0.35);
|
||||
}
|
||||
@@ -24,9 +24,14 @@
|
||||
// Rules
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Navigation tabs
|
||||
[data-md-color-primary] .md-tabs {
|
||||
position: absolute;
|
||||
top: px2rem(48px);
|
||||
background-color: transparent;
|
||||
// Icon
|
||||
.md-icon {
|
||||
|
||||
// SVG defaults
|
||||
svg {
|
||||
display: block;
|
||||
width: px2rem(24px);
|
||||
height: px2rem(24px);
|
||||
fill: currentcolor;
|
||||
}
|
||||
}
|
||||
118
src/templates/assets/stylesheets/main/_resets.scss
Normal file
118
src/templates/assets/stylesheets/main/_resets.scss
Normal file
@@ -0,0 +1,118 @@
|
||||
////
|
||||
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
///
|
||||
/// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
/// copy of this software and associated documentation files (the "Software"),
|
||||
/// to deal in the Software without restriction, including without limitation
|
||||
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
/// and/or sell copies of the Software, and to permit persons to whom the
|
||||
/// Software is furnished to do so, subject to the following conditions:
|
||||
///
|
||||
/// The above copyright notice and this permission notice shall be included in
|
||||
/// all copies or substantial portions of the Software.
|
||||
///
|
||||
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
/// DEALINGS
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Enforce correct box model and prevent adjustments of font size after
|
||||
// orientation changes in IE and iOS
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
text-size-adjust: none;
|
||||
}
|
||||
|
||||
// All elements shall inherit the document default
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: inherit;
|
||||
|
||||
// [reduced motion]: Disable all transitions
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important; // stylelint-disable-line
|
||||
}
|
||||
}
|
||||
|
||||
// Remove margin in all browsers
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Reset tap outlines on iOS and Android
|
||||
a,
|
||||
button,
|
||||
label,
|
||||
input {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
// Reset link styles
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
// Normalize horizontal separator styles
|
||||
hr {
|
||||
box-sizing: content-box;
|
||||
display: block;
|
||||
height: px2rem(1px);
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
// Normalize font-size in all browsers
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
// Prevent subscript and superscript from affecting line-height
|
||||
sub,
|
||||
sup {
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
// Remove border on image
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
// Reset table styles
|
||||
table {
|
||||
border-spacing: 0;
|
||||
border-collapse: separate;
|
||||
}
|
||||
|
||||
// Reset table cell styles
|
||||
td,
|
||||
th {
|
||||
font-weight: 400;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
// Reset button styles
|
||||
button {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
// Reset input styles
|
||||
input {
|
||||
border: 0;
|
||||
outline: none;
|
||||
}
|
||||
623
src/templates/assets/stylesheets/main/_typeset.scss
Normal file
623
src/templates/assets/stylesheets/main/_typeset.scss
Normal file
@@ -0,0 +1,623 @@
|
||||
////
|
||||
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
///
|
||||
/// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
/// copy of this software and associated documentation files (the "Software"),
|
||||
/// to deal in the Software without restriction, including without limitation
|
||||
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
/// and/or sell copies of the Software, and to permit persons to whom the
|
||||
/// Software is furnished to do so, subject to the following conditions:
|
||||
///
|
||||
/// The above copyright notice and this permission notice shall be included in
|
||||
/// all copies or substantial portions of the Software.
|
||||
///
|
||||
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
/// DEALINGS
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules: font definitions
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Enable font-smoothing in Webkit and FF
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
// Font with fallback for body copy
|
||||
--md-text-font-family:
|
||||
var(--md-text-font, _),
|
||||
-apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
|
||||
|
||||
// Font with fallback for code
|
||||
--md-code-font-family:
|
||||
var(--md-code-font, _),
|
||||
SFMono-Regular, Consolas, Menlo, monospace;
|
||||
}
|
||||
|
||||
// Define default fonts
|
||||
body,
|
||||
input,
|
||||
aside {
|
||||
font-family: var(--md-text-font-family);
|
||||
font-feature-settings: "kern", "liga";
|
||||
color: var(--md-typeset-color);
|
||||
}
|
||||
|
||||
// Define monospaced fonts
|
||||
code,
|
||||
pre,
|
||||
kbd {
|
||||
font-family: var(--md-code-font-family);
|
||||
font-feature-settings: "kern";
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules: typesetted content
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// General variables
|
||||
:root {
|
||||
--md-typeset-table-sort-icon: svg-load("material/sort.svg");
|
||||
--md-typeset-table-sort-icon--asc: svg-load("material/sort-ascending.svg");
|
||||
--md-typeset-table-sort-icon--desc: svg-load("material/sort-descending.svg");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Content that is typeset - if possible, all margins, paddings and font sizes
|
||||
// should be set in ems, so nested blocks (e.g. admonitions) render correctly.
|
||||
.md-typeset {
|
||||
font-size: px2rem(16px);
|
||||
line-height: 1.6;
|
||||
overflow-wrap: break-word;
|
||||
color-adjust: exact;
|
||||
|
||||
// [print]: We'll use a smaller `font-size` for printing, so code examples
|
||||
// don't break too early, and `16px` looks too big anyway.
|
||||
@media print {
|
||||
font-size: px2rem(13.6px);
|
||||
}
|
||||
|
||||
// Default spacing
|
||||
ul,
|
||||
ol,
|
||||
dl,
|
||||
figure,
|
||||
blockquote,
|
||||
pre {
|
||||
margin-block: 1em;
|
||||
}
|
||||
|
||||
// Headline on level 1
|
||||
h1 {
|
||||
margin: 0 0 px2em(40px, 32px);
|
||||
font-size: px2em(32px);
|
||||
font-weight: 300;
|
||||
line-height: 1.3;
|
||||
color: var(--md-default-fg-color--light);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
// Headline on level 2
|
||||
h2 {
|
||||
margin: px2em(40px, 25px) 0 px2em(16px, 25px);
|
||||
font-size: px2em(25px);
|
||||
font-weight: 300;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
// Headline on level 3
|
||||
h3 {
|
||||
margin: px2em(32px, 20px) 0 px2em(16px, 20px);
|
||||
font-size: px2em(20px);
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
// Headline on level 3 following level 2
|
||||
h2 + h3 {
|
||||
margin-top: px2em(16px, 20px);
|
||||
}
|
||||
|
||||
// Headline on level 4
|
||||
h4 {
|
||||
margin: px2em(16px) 0;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
// Headline on level 5-6
|
||||
h5,
|
||||
h6 {
|
||||
margin: px2em(16px, 12.8px) 0;
|
||||
font-size: px2em(12.8px);
|
||||
font-weight: 700;
|
||||
color: var(--md-default-fg-color--light);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
// Headline on level 5
|
||||
h5 {
|
||||
text-transform: uppercase;
|
||||
|
||||
// Don't uppercase code blocks
|
||||
code {
|
||||
text-transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal separator
|
||||
hr {
|
||||
display: flow-root;
|
||||
margin: 1.5em 0;
|
||||
border-bottom: px2rem(1px) solid var(--md-default-fg-color--lightest);
|
||||
}
|
||||
|
||||
// Text link
|
||||
a {
|
||||
color: var(--md-typeset-a-color);
|
||||
word-break: break-word;
|
||||
|
||||
// Also enable color transition on pseudo elements
|
||||
&,
|
||||
&::before {
|
||||
transition: color 125ms;
|
||||
}
|
||||
|
||||
// Text link on focus/hover
|
||||
&:is(:focus, :hover) {
|
||||
color: var(--md-accent-fg-color);
|
||||
|
||||
// Inline code block
|
||||
code {
|
||||
color: var(--md-accent-fg-color);
|
||||
background-color: var(--md-accent-fg-color--transparent);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline code block
|
||||
code {
|
||||
color: var(--md-typeset-a-color);
|
||||
}
|
||||
|
||||
// Show outline for keyboard devices
|
||||
&.focus-visible {
|
||||
outline-color: var(--md-accent-fg-color);
|
||||
outline-offset: px2rem(4px);
|
||||
}
|
||||
}
|
||||
|
||||
// Code block
|
||||
code,
|
||||
pre,
|
||||
kbd {
|
||||
font-variant-ligatures: none;
|
||||
color: var(--md-code-fg-color);
|
||||
direction: ltr;
|
||||
transition: background-color 125ms;
|
||||
|
||||
// [print]: Wrap text and hide scollbars
|
||||
@media print {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// Inline code block
|
||||
code {
|
||||
padding: 0 px2em(4px, 13.6px);
|
||||
font-size: px2em(13.6px);
|
||||
word-break: break-word;
|
||||
background-color: var(--md-code-bg-color);
|
||||
border-radius: px2rem(2px);
|
||||
box-decoration-break: clone;
|
||||
transition:
|
||||
color 125ms,
|
||||
background-color 125ms;
|
||||
|
||||
// Hide outline for pointer devices
|
||||
&:not(.focus-visible) {
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// Unformatted content
|
||||
pre {
|
||||
position: relative;
|
||||
display: flow-root;
|
||||
line-height: 1.4;
|
||||
|
||||
// Code block
|
||||
> code {
|
||||
display: block;
|
||||
padding: px2em(10.5px, 13.6px) px2em(16px, 13.6px);
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
word-break: normal;
|
||||
touch-action: auto;
|
||||
scrollbar-color: var(--md-default-fg-color--lighter) transparent;
|
||||
scrollbar-width: thin;
|
||||
outline-color: var(--md-accent-fg-color);
|
||||
box-shadow: none;
|
||||
box-decoration-break: slice;
|
||||
|
||||
// Code block on hover
|
||||
&:hover {
|
||||
scrollbar-color: var(--md-accent-fg-color) transparent;
|
||||
}
|
||||
|
||||
// Webkit scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: px2rem(4px);
|
||||
height: px2rem(4px);
|
||||
}
|
||||
|
||||
// Webkit scrollbar thumb
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--md-default-fg-color--lighter);
|
||||
|
||||
// Webkit scrollbar thumb on hover
|
||||
&:hover {
|
||||
background-color: var(--md-accent-fg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard key
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 0 px2em(8px, 12px);
|
||||
font-size: px2em(12px);
|
||||
color: var(--md-default-fg-color);
|
||||
word-break: break-word;
|
||||
vertical-align: text-top;
|
||||
background-color: var(--md-typeset-kbd-color);
|
||||
border-radius: px2rem(2px);
|
||||
box-shadow:
|
||||
0 px2rem(2px) 0 px2rem(1px) var(--md-typeset-kbd-border-color),
|
||||
0 px2rem(2px) 0 var(--md-typeset-kbd-border-color),
|
||||
0 px2rem(-2px) px2rem(4px) var(--md-typeset-kbd-accent-color) inset;
|
||||
}
|
||||
|
||||
// Text highlighting marker
|
||||
mark {
|
||||
color: inherit;
|
||||
word-break: break-word;
|
||||
background-color: var(--md-typeset-mark-color);
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
|
||||
// Abbreviation
|
||||
abbr {
|
||||
text-decoration: none;
|
||||
cursor: help;
|
||||
border-bottom: px2rem(1px) dotted var(--md-default-fg-color--light);
|
||||
}
|
||||
|
||||
// Instant previews
|
||||
[data-preview] {
|
||||
border-bottom: px2rem(1px) dotted var(--md-default-fg-color--light);
|
||||
}
|
||||
|
||||
// Small text
|
||||
small {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
// Superscript and subscript
|
||||
sup,
|
||||
sub {
|
||||
margin-inline-start: px2em(1px, 12.8px);
|
||||
}
|
||||
|
||||
// Blockquotes, possibly nested
|
||||
blockquote {
|
||||
padding-inline-start: px2rem(12px);
|
||||
margin-inline: 0;
|
||||
color: var(--md-default-fg-color--light);
|
||||
border-inline-start: px2rem(4px) solid var(--md-default-fg-color--lighter);
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
|
||||
// Hack: allow to override `list-style-type` via `type`, without breaking
|
||||
// compatibility for explicitly setting it in CSS - see https://t.ly/izJyH
|
||||
&[type] {
|
||||
list-style-type: revert-layer;
|
||||
}
|
||||
}
|
||||
|
||||
// Unordered and ordered list
|
||||
ul,
|
||||
ol {
|
||||
padding: 0;
|
||||
margin-inline-start: px2em(10px);
|
||||
|
||||
// Adjust display mode if not hidden
|
||||
&:not([hidden]) {
|
||||
display: flow-root;
|
||||
}
|
||||
|
||||
// 2nd layer nested ordered list
|
||||
ol {
|
||||
list-style-type: lower-alpha;
|
||||
|
||||
// 3rd layer nested ordered list
|
||||
ol {
|
||||
list-style-type: lower-roman;
|
||||
|
||||
// 4th layer nested ordered list
|
||||
ol {
|
||||
list-style-type: upper-alpha;
|
||||
|
||||
// 5th layer nested ordered list
|
||||
ol {
|
||||
list-style-type: upper-roman;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hack: allow to override `list-style-type` via `type`, without breaking
|
||||
// compatibility for explicitly setting it in CSS - see https://t.ly/izJyH
|
||||
&[type] {
|
||||
list-style-type: revert-layer;
|
||||
}
|
||||
|
||||
// List element
|
||||
li {
|
||||
margin-inline-start: px2em(20px);
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
// Adjust spacing
|
||||
p,
|
||||
blockquote {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
// Adjust spacing on last child
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// Nested list
|
||||
:is(ul, ol) {
|
||||
margin-block: 0.5em;
|
||||
margin-inline-start: px2em(10px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Definition list
|
||||
dd {
|
||||
margin-block: 1em 1.5em;
|
||||
margin-inline-start: px2em(30px);
|
||||
}
|
||||
|
||||
// Image or video
|
||||
img,
|
||||
svg,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
// Image
|
||||
img {
|
||||
|
||||
// Adjust spacing when left-aligned
|
||||
&[align="left"] {
|
||||
margin: 1em;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
// Adjust spacing when right-aligned
|
||||
&[align="right"] {
|
||||
margin: 1em;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
// Adjust spacing when sole children
|
||||
&[align]:only-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Figure
|
||||
figure {
|
||||
display: flow-root;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 1em auto;
|
||||
text-align: center;
|
||||
|
||||
// Figure images
|
||||
img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Figure caption
|
||||
figcaption {
|
||||
max-width: px2rem(480px);
|
||||
margin: 1em auto;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Limit width to container
|
||||
iframe {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
// Data table
|
||||
table:not([class]) {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
font-size: px2rem(12.8px);
|
||||
touch-action: auto;
|
||||
background-color: var(--md-default-bg-color);
|
||||
border: px2rem(1px) solid var(--md-typeset-table-color);
|
||||
border-radius: px2rem(2px);
|
||||
|
||||
// [print]: Reset display mode so table header wraps when printing
|
||||
@media print {
|
||||
display: table;
|
||||
}
|
||||
|
||||
// Due to margin collapse because of the necessary inline-block hack, we
|
||||
// cannot increase the bottom margin on the table, so we just increase the
|
||||
// top margin on the following element
|
||||
+ * {
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
// Elements in table heading and cell
|
||||
:is(th, td) > * {
|
||||
|
||||
// Adjust spacing on first child
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
// Adjust spacing on last child
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Table heading and cell
|
||||
:is(th, td):not([align]) {
|
||||
text-align: left;
|
||||
|
||||
// Adjust for right-to-left languages
|
||||
[dir="rtl"] & {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
// Table heading
|
||||
th {
|
||||
min-width: px2rem(100px);
|
||||
padding: px2em(12px, 12.8px) px2em(16px, 12.8px);
|
||||
font-weight: 700;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
// Table cell
|
||||
td {
|
||||
padding: px2em(12px, 12.8px) px2em(16px, 12.8px);
|
||||
vertical-align: top;
|
||||
border-top: px2rem(1px) solid var(--md-typeset-table-color);
|
||||
}
|
||||
|
||||
// Table body row
|
||||
tbody tr {
|
||||
transition: background-color 125ms;
|
||||
|
||||
// Table row on hover
|
||||
&:hover {
|
||||
background-color: var(--md-typeset-table-color--light);
|
||||
box-shadow: 0 px2rem(1px) 0 var(--md-default-bg-color) inset;
|
||||
}
|
||||
}
|
||||
|
||||
// Text link in table
|
||||
a {
|
||||
word-break: normal;
|
||||
}
|
||||
}
|
||||
|
||||
// Sortable table
|
||||
table th[role="columnheader"] {
|
||||
cursor: pointer;
|
||||
|
||||
// Sort icon
|
||||
&::after {
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
margin-inline-start: 0.5em;
|
||||
vertical-align: text-bottom;
|
||||
content: "";
|
||||
mask-image: var(--md-typeset-table-sort-icon);
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
transition: background-color 125ms;
|
||||
}
|
||||
|
||||
// Show sort icon on hover
|
||||
&:hover::after {
|
||||
background-color: var(--md-default-fg-color--lighter);
|
||||
}
|
||||
|
||||
// Sort ascending icon
|
||||
&[aria-sort="ascending"]::after {
|
||||
background-color: var(--md-default-fg-color--light);
|
||||
mask-image: var(--md-typeset-table-sort-icon--asc);
|
||||
}
|
||||
|
||||
// Sort descending icon
|
||||
&[aria-sort="descending"]::after {
|
||||
background-color: var(--md-default-fg-color--light);
|
||||
mask-image: var(--md-typeset-table-sort-icon--desc);
|
||||
}
|
||||
}
|
||||
|
||||
// Data table scroll wrapper
|
||||
&__scrollwrap {
|
||||
margin: 1em px2rem(-16px);
|
||||
overflow-x: auto;
|
||||
touch-action: auto;
|
||||
}
|
||||
|
||||
// Data table wrapper
|
||||
&__table {
|
||||
display: inline-block;
|
||||
padding: 0 px2rem(16px);
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
// [print]: Reset display mode so table header wraps when printing
|
||||
@media print {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Data table
|
||||
html & table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules: top-level
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// [mobile -]: Align with body copy
|
||||
@include break-to-device(mobile) {
|
||||
|
||||
// Top-level unformatted content
|
||||
.md-content__inner > pre {
|
||||
margin: 1em px2rem(-16px);
|
||||
|
||||
// Code block
|
||||
code {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
////
|
||||
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
///
|
||||
/// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
/// copy of this software and associated documentation files (the "Software"),
|
||||
/// to deal in the Software without restriction, including without limitation
|
||||
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
/// and/or sell copies of the Software, and to permit persons to whom the
|
||||
/// Software is furnished to do so, subject to the following conditions:
|
||||
///
|
||||
/// The above copyright notice and this permission notice shall be included in
|
||||
/// all copies or substantial portions of the Software.
|
||||
///
|
||||
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
/// DEALINGS
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Scoped in typesetted content to match specificity of regular content
|
||||
.md-typeset {
|
||||
|
||||
// Author, i.e., GitHub user
|
||||
.md-author {
|
||||
position: relative;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: px2rem(32px);
|
||||
height: px2rem(32px);
|
||||
overflow: hidden;
|
||||
border-radius: 100%;
|
||||
transition:
|
||||
color 125ms,
|
||||
transform 125ms;
|
||||
|
||||
// Author image
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// More authors
|
||||
&--more {
|
||||
font-size: px2rem(12px);
|
||||
font-weight: 700;
|
||||
line-height: px2rem(32px);
|
||||
color: var(--md-default-fg-color--lighter);
|
||||
text-align: center;
|
||||
background: var(--md-default-fg-color--lightest);
|
||||
}
|
||||
|
||||
// Enlarge image
|
||||
&--long {
|
||||
width: px2rem(48px);
|
||||
height: px2rem(48px);
|
||||
}
|
||||
}
|
||||
|
||||
// Author link
|
||||
a.md-author {
|
||||
transform: scale(1);
|
||||
|
||||
// Author image
|
||||
img {
|
||||
filter: grayscale(100%) opacity(75%);
|
||||
// Hack: also apply this here, in order to mitigate browser glitches in
|
||||
// Chrome and Edge when hovering the avatar - see https://t.ly/Q3ECC
|
||||
border-radius: 100%;
|
||||
transition: filter 125ms;
|
||||
}
|
||||
|
||||
// Author on focus/hover
|
||||
&:is(:focus, :hover) {
|
||||
z-index: 1;
|
||||
transform: scale(1.1);
|
||||
|
||||
// Author image
|
||||
img {
|
||||
filter: grayscale(0%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user