Merge of features tied to 'Piri Piri' funding goal'

This commit is contained in:
squidfunk 2023-07-05 17:39:33 +02:00
parent 03d065ca20
commit 1bee037713
No known key found for this signature in database
GPG Key ID: 5ED40BC4F9C436DF
100 changed files with 4610 additions and 671 deletions

View File

@ -23,5 +23,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}
<script src="{{ 'assets/javascripts/custom.a678ee80.min.js' | url }}"></script> <script src="{{ 'assets/javascripts/custom.98e0b405.min.js' | url }}"></script>
{% endblock %} {% endblock %}

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

File diff suppressed because one or more lines are too long

View File

@ -27,6 +27,10 @@
{% if page.next_page %} {% if page.next_page %}
<link rel="next" href="{{ page.next_page.url | url }}"> <link rel="next" href="{{ page.next_page.url | url }}">
{% endif %} {% 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 }}"> <link rel="icon" href="{{ config.theme.favicon | url }}">
<meta name="generator" content="mkdocs-{{ mkdocs_version }}, mkdocs-material-9.1.18"> <meta name="generator" content="mkdocs-{{ mkdocs_version }}, mkdocs-material-9.1.18">
{% endblock %} {% endblock %}
@ -40,7 +44,7 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block styles %} {% block styles %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.26e3688c.min.css' | url }}"> <link rel="stylesheet" href="{{ 'assets/stylesheets/main.08fbbb04.min.css' | url }}">
{% if config.theme.palette %} {% if config.theme.palette %}
{% set palette = config.theme.palette %} {% set palette = config.theme.palette %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.ecc896b0.min.css' | url }}"> <link rel="stylesheet" href="{{ 'assets/stylesheets/palette.ecc896b0.min.css' | url }}">
@ -212,7 +216,7 @@
"base": base_url, "base": base_url,
"features": features, "features": features,
"translations": {}, "translations": {},
"search": "assets/javascripts/workers/search.74e28a9f.min.js" | url "search": "assets/javascripts/workers/search.780af0f4.min.js" | url
} -%} } -%}
{%- if config.extra.version -%} {%- if config.extra.version -%}
{%- set _ = app.update({ "version": config.extra.version }) -%} {%- set _ = app.update({ "version": config.extra.version }) -%}
@ -240,7 +244,7 @@
</script> </script>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ 'assets/javascripts/bundle.220ee61c.min.js' | url }}"></script> <script src="{{ 'assets/javascripts/bundle.202856ae.min.js' | url }}"></script>
{% for path in config.extra_javascript %} {% for path in config.extra_javascript %}
{% if path.endswith(".mjs") %} {% if path.endswith(".mjs") %}
<script type="module" src="{{ path | url }}"></script> <script type="module" src="{{ path | url }}"></script>

View File

@ -0,0 +1,16 @@
{#-
This file was automatically generated - do not edit
-#}
{% extends "main.html" %}
{% block container %}
<div class="md-content" data-md-component="content">
<div class="md-content__inner">
<header class="md-typeset">
{{ page.content }}
</header>
{% for post in posts %}
{% include "partials/post.html" %}
{% endfor %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,16 @@
{#-
This file was automatically generated - do not edit
-#}
{% extends "main.html" %}
{% block container %}
<div class="md-content" data-md-component="content">
<div class="md-content__inner">
<header class="md-typeset">
{{ page.content }}
</header>
{% for post in posts %}
{% include "partials/post.html" %}
{% endfor %}
</div>
</div>
{% endblock %}

98
material/blog-post.html Normal file
View File

@ -0,0 +1,98 @@
{#-
This file was automatically generated - do not edit
-#}
{% extends "main.html" %}
{% import "partials/nav-item.html" as item with context %}
{% block container %}
<div class="md-content md-content--post" data-md-component="content">
<div class="md-sidebar md-sidebar--post" data-md-component="sidebar" data-md-type="navigation">
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner md-post">
<nav class="md-nav">
<div class="md-post__back">
<div class="md-nav__title md-nav__container">
<a href="{{ page.parent.url | url }}" class="md-nav__link">
{% include ".icons/material/arrow-left.svg" %}
<span class="md-ellipsis">
{{ lang.t("blog.index") }}
</span>
</a>
</div>
</div>
{% if page.authors %}
<div class="md-post__authors md-typeset">
{% for author in page.authors %}
<div class="md-profile md-post__profile">
<span class="md-author md-author--long">
<img src="{{ author.avatar }}" alt="{{ author.name }}">
</span>
<span class="md-profile__description">
<strong>{{ author.name }}</strong><br>
{{ author.description }}
</span>
</div>
{% endfor %}
</div>
{% endif %}
<ul class="md-post__meta md-nav__list">
<li class="md-nav__item md-nav__title">
<div class="md-nav__link">
<span class="md-ellipsis">
{{ lang.t("blog.meta") }}
</span>
</div>
</li>
<li class="md-nav__item">
<div class="md-nav__link">
{% include ".icons/material/calendar.svg" %}
<time datetime="{{ page.meta.date }}" class="md-ellipsis">
{{- page.meta.date_format -}}
</time>
</div>
</li>
{% if page.categories %}
<li class="md-nav__item">
<div class="md-nav__link">
{% include ".icons/material/bookshelf.svg" %}
<span class="md-ellipsis">
{{ lang.t("blog.categories.in") }}
{% for category in page.categories %}
<a href="{{ category.url | url }}">
{{- category.title -}}
</a>
{%- if loop.revindex > 1 %}, {% endif -%}
{% endfor -%}
</span>
</div>
</li>
{% endif %}
{% if page.meta.readtime %}
{% set time = page.meta.readtime %}
<li class="md-nav__item">
<div class="md-nav__link">
{% include ".icons/material/clock-outline.svg" %}
<span class="md-ellipsis">
{% if time == 1 %}
{{ lang.t("readtime.one") }}
{% else %}
{{ lang.t("readtime.other") | replace("#", time) }}
{% endif %}
</span>
</div>
</li>
{% endif %}
</ul>
</nav>
{% if "toc.integrate" in features %}
{% include "partials/toc.html" %}
{% endif %}
</div>
</div>
</div>
<article class="md-content__inner md-typeset">
{% block content %}
{% include "partials/content.html" %}
{% endblock %}
</article>
</div>
{% endblock %}

19
material/blog.html Normal file
View File

@ -0,0 +1,19 @@
{#-
This file was automatically generated - do not edit
-#}
{% extends "main.html" %}
{% block container %}
<div class="md-content" data-md-component="content">
<div class="md-content__inner">
<header class="md-typeset">
{{ page.content }}
</header>
{% for post in posts %}
{% include "partials/post.html" %}
{% endfor %}
{% block pagination %}
{% include "partials/pagination.html" %}
{% endblock %}
</div>
</div>
{% endblock %}

View File

@ -3,4 +3,4 @@
-#} -#}
{% import "partials/languages/" ~ config.theme.language ~ ".html" as lang %} {% import "partials/languages/" ~ config.theme.language ~ ".html" as lang %}
{% import "partials/languages/en.html" as fallback %} {% import "partials/languages/en.html" as fallback %}
{% macro t(key) %}{{ lang.t(key) or fallback.t(key) }}{% endmacro %} {% macro t(key) %}{{ lang.t(key) or fallback.t(key) or key }}{% endmacro %}

View File

@ -26,9 +26,8 @@
"header": "頁首", "header": "頁首",
"meta.comments": "評論", "meta.comments": "評論",
"meta.source": "來源", "meta.source": "來源",
"search.config.lang": "ja",
"search.config.pipeline": "stemmer", "search.config.pipeline": "stemmer",
"search.config.separator": "[\\s\\-,。]+", "search.config.separator": "[\\s\\u200b\\u3000\\-、。,.?!;]+",
"nav": "導航", "nav": "導航",
"readtime.one": "需要 1 分鐘閲讀", "readtime.one": "需要 1 分鐘閲讀",
"readtime.other": "需要 # 分鐘閲讀", "readtime.other": "需要 # 分鐘閲讀",

View File

@ -32,9 +32,8 @@
"rss.created": "RSS 訂閱", "rss.created": "RSS 訂閱",
"rss.updated": "RSS 訂閱內容已更新", "rss.updated": "RSS 訂閱內容已更新",
"search": "搜尋", "search": "搜尋",
"search.config.lang": "ja",
"search.config.pipeline": "stemmer", "search.config.pipeline": "stemmer",
"search.config.separator": "[\\s\\- 、。,.?;]+", "search.config.separator": "[\\s\\u200b\\u3000\\-、。,.?]+",
"search.placeholder": "搜尋", "search.placeholder": "搜尋",
"search.share": "分享", "search.share": "分享",
"search.reset": "清除", "search.reset": "清除",

View File

@ -32,9 +32,8 @@
"rss.created": "RSS 订阅", "rss.created": "RSS 订阅",
"rss.updated": "已更新内容的 RSS 订阅", "rss.updated": "已更新内容的 RSS 订阅",
"search": "查找", "search": "查找",
"search.config.lang": "ja",
"search.config.pipeline": "stemmer", "search.config.pipeline": "stemmer",
"search.config.separator": "[\\s\\-,。]+", "search.config.separator": "[\\s\\u200b\\u3000\\-、。,.?!;]+",
"search.placeholder": "搜索", "search.placeholder": "搜索",
"search.share": "分享", "search.share": "分享",
"search.reset": "清空当前内容", "search.reset": "清空当前内容",

View File

@ -1,25 +1,45 @@
{#- {#-
This file was automatically generated - do not edit This file was automatically generated - do not edit
-#} -#}
{% macro render_status(nav_item, type) %}
{% set class = "md-status md-status--" ~ type %}
{% if config.extra.status and config.extra.status[type] %}
<span class="{{ class }}" title="{{ config.extra.status[type] }}">
</span>
{% else %}
<span class="{{ class }}"></span>
{% endif %}
{% endmacro %}
{% macro render_content(nav_item, ref = nav_item) %}
{% if nav_item.is_page and nav_item.meta.icon %}
{% include ".icons/" ~ nav_item.meta.icon ~ ".svg" %}
{% endif %}
<span class="md-ellipsis">
{{ nav_item.title }}
</span>
{% if nav_item.is_page and nav_item.meta.status %}
{{ render_status(nav_item, nav_item.meta.status) }}
{% endif %}
{% endmacro %}
{% macro render_pruned(nav_item, ref = nav_item) %}
{% set first = nav_item.children | first %}
{% if first and first.children %}
{{ render_pruned(first, ref) }}
{% else %}
<a href="{{ first.url | url }}" class="md-nav__link">
{{ render_content(first, ref) }}
{% if nav_item.children | length > 1 %}
<span class="md-nav__icon md-icon"></span>
{% endif %}
</a>
{% endif %}
{% endmacro %}
{% macro render(nav_item, path, level) %} {% macro render(nav_item, path, level) %}
{% set class = "md-nav__item" %} {% set class = "md-nav__item" %}
{% if nav_item.active %} {% if nav_item.active %}
{% set class = class ~ " md-nav__item--active" %} {% set class = class ~ " md-nav__item--active" %}
{% endif %} {% endif %}
{% if nav_item.children %} {% if nav_item.children %}
{% if "navigation.sections" in features and level == 1 + (
"navigation.tabs" in features
) %}
{% set class = class ~ " md-nav__item--section" %}
{% endif %}
<li class="{{ class }} md-nav__item--nested">
{% set expanded = "navigation.expand" in features %}
{% set active = nav_item.active or expanded %}
{% set checked = "checked" if nav_item.active %}
{% if expanded and not checked %}
{% set indeterminate = "md-toggle--indeterminate" %}
{% endif %}
<input class="md-nav__toggle md-toggle {{ indeterminate }}" type="checkbox" id="{{ path }}" {{ checked }}>
{% set indexes = [] %} {% set indexes = [] %}
{% if "navigation.indexes" in features %} {% if "navigation.indexes" in features %}
{% for nav_item in nav_item.children %} {% for nav_item in nav_item.children %}
@ -28,18 +48,37 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if "navigation.sections" in features and level == 1 + (
"navigation.tabs" in features
) %}
{% set class = class ~ " md-nav__item--section" %}
{% elif not nav_item.active and "navigation.prune" in features %}
{% set class = class ~ " md-nav__item--pruned" %}
{% set prune = true %}
{% endif %}
<li class="{{ class }} md-nav__item--nested">
{% if not prune %}
{% set expanded = "navigation.expand" in features %}
{% set active = nav_item.active or expanded %}
{% set checked = "checked" if nav_item.active %}
{% if expanded and not checked %}
{% set indeterminate = "md-toggle--indeterminate" %}
{% endif %}
<input class="md-nav__toggle md-toggle {{ indeterminate }}" type="checkbox" id="{{ path }}" {{ checked }}>
{% if not indexes %} {% if not indexes %}
<label class="md-nav__link" for="{{ path }}" id="{{ path }}_label" tabindex="0"> <label class="md-nav__link" for="{{ path }}" id="{{ path }}_label" tabindex="0">
{{ nav_item.title }} {{ render_content(nav_item) }}
<span class="md-nav__icon md-icon"></span> <span class="md-nav__icon md-icon"></span>
</label> </label>
{% else %} {% else %}
{% set index = indexes | first %} {% set index = indexes | first %}
{% set class = "md-nav__link--active" if index == page %} {% set class = "md-nav__link--active" if index == page %}
<div class="md-nav__link md-nav__link--index {{ class }}"> <div class="md-nav__link md-nav__container">
<a href="{{ index.url | url }}">{{ nav_item.title }}</a> <a href="{{ index.url | url }}" class="md-nav__link {{ class }}">
{{ render_content(index, nav_item) }}
</a>
{% if nav_item.children | length > 1 %} {% if nav_item.children | length > 1 %}
<label for="{{ path }}"> <label class="md-nav__link {{ class }}" for="{{ path }}">
<span class="md-nav__icon md-icon"></span> <span class="md-nav__icon md-icon"></span>
</label> </label>
{% endif %} {% endif %}
@ -58,6 +97,9 @@
{% endfor %} {% endfor %}
</ul> </ul>
</nav> </nav>
{% else %}
{{ render_pruned(nav_item) }}
{% endif %}
</li> </li>
{% elif nav_item == page %} {% elif nav_item == page %}
<li class="{{ class }}"> <li class="{{ class }}">
@ -69,12 +111,12 @@
{% endif %} {% endif %}
{% if toc %} {% if toc %}
<label class="md-nav__link md-nav__link--active" for="__toc"> <label class="md-nav__link md-nav__link--active" for="__toc">
{{ nav_item.title }} {{ render_content(nav_item) }}
<span class="md-nav__icon md-icon"></span> <span class="md-nav__icon md-icon"></span>
</label> </label>
{% endif %} {% endif %}
<a href="{{ nav_item.url | url }}" class="md-nav__link md-nav__link--active"> <a href="{{ nav_item.url | url }}" class="md-nav__link md-nav__link--active">
{{ nav_item.title }} {{ render_content(nav_item) }}
</a> </a>
{% if toc %} {% if toc %}
{% include "partials/toc.html" %} {% include "partials/toc.html" %}
@ -83,9 +125,8 @@
{% else %} {% else %}
<li class="{{ class }}"> <li class="{{ class }}">
<a href="{{ nav_item.url | url }}" class="md-nav__link"> <a href="{{ nav_item.url | url }}" class="md-nav__link">
{{ nav_item.title }} {{ render_content(nav_item) }}
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{{ render(nav_item, path, level) }}

View File

@ -1,6 +1,7 @@
{#- {#-
This file was automatically generated - do not edit This file was automatically generated - do not edit
-#} -#}
{% import "partials/nav-item.html" as item with context %}
{% set class = "md-nav md-nav--primary" %} {% set class = "md-nav md-nav--primary" %}
{% if "navigation.tabs" in features %} {% if "navigation.tabs" in features %}
{% set class = class ~ " md-nav--lifted" %} {% set class = class ~ " md-nav--lifted" %}
@ -23,8 +24,7 @@
<ul class="md-nav__list" data-md-scrollfix> <ul class="md-nav__list" data-md-scrollfix>
{% for nav_item in nav %} {% for nav_item in nav %}
{% set path = "__nav_" ~ loop.index %} {% set path = "__nav_" ~ loop.index %}
{% set level = 1 %} {{ item.render(nav_item, path, 1) }}
{% include "partials/nav-item.html" %}
{% endfor %} {% endfor %}
</ul> </ul>
</nav> </nav>

View File

@ -0,0 +1,20 @@
{#-
This file was automatically generated - do not edit
-#}
{% import ".icons/material/chevron-double-left.svg" as icon_first %}
{% import ".icons/material/chevron-left.svg" as icon_previous %}
{% import ".icons/material/chevron-right.svg" as icon_next %}
{% import ".icons/material/chevron-double-right.svg" as icon_last %}
<nav class="md-pagination">
{{
pagination({
"link_attr": { "class": "md-pagination__link" },
"curpage_attr": { "class": "md-pagination__current" },
"dotdot_attr": { "class": "md-pagination__dots" },
"symbol_first": icon_first,
"symbol_previous": icon_previous,
"symbol_next": icon_next,
"symbol_last": icon_last
})
}}
</nav>

View File

@ -0,0 +1,60 @@
{#-
This file was automatically generated - do not edit
-#}
<article class="md-post md-post--excerpt">
<header class="md-post__header">
{% if post.authors %}
<nav class="md-post__authors md-typeset">
{% for author in post.authors %}
<span class="md-author">
<img src="{{ author.avatar }}" alt="{{ author.name }}">
</span>
{% endfor %}
</nav>
{% endif %}
<div class="md-post__meta md-meta">
<ul class="md-meta__list">
<li class="md-meta__item">
<time datetime="{{ post.meta.date }}">
{{- post.meta.date_format -}}
</time>
{#- Collapse whitespace -#}
</li>
{% if post.categories %}
<li class="md-meta__item">
{{ lang.t("blog.categories.in") }}
{% for category in post.categories %}
<a href="{{ category.url | url }}" class="md-meta__link">
{{- category.title -}}
</a>
{%- if loop.revindex > 1 %}, {% endif -%}
{% endfor -%}
</li>
{% endif %}
{% if post.meta.readtime %}
{% set time = post.meta.readtime %}
<li class="md-meta__item">
{% if time == 1 %}
{{ lang.t("readtime.one") }}
{% else %}
{{ lang.t("readtime.other") | replace("#", time) }}
{% endif %}
</li>
{% endif %}
</ul>
{% if post.meta.draft %}
<span class="md-draft">
{{ lang.t("blog.draft") }}
</span>
{% endif %}
</div>
</header>
<div class="md-post__content md-typeset">
{{ post.content }}
<nav class="md-post__action">
<a href="{{ post.url | url }}">
{{ lang.t("blog.continue") }}
</a>
</nav>
</div>
</article>

View File

@ -1,28 +1,35 @@
{#- {#-
This file was automatically generated - do not edit This file was automatically generated - do not edit
-#} -#}
{% if not class %} {% macro render_content(nav_item, ref = nav_item) %}
{% if nav_item == ref or "navigation.indexes" in features %}
{% if nav_item.is_index and nav_item.meta.icon %}
{% include ".icons/" ~ nav_item.meta.icon ~ ".svg" %}
{% endif %}
{% endif %}
{{ ref.title }}
{% endmacro %}
{% macro render(nav_item, ref = nav_item) %}
{% set class = "md-tabs__link" %} {% set class = "md-tabs__link" %}
{% if nav_item.active %} {% if ref.active %}
{% set class = class ~ " md-tabs__link--active" %} {% set class = class ~ " md-tabs__link--active" %}
{% endif %} {% endif %}
{% endif %}
{% if nav_item.children %} {% if nav_item.children %}
{% set title = title | d(nav_item.title) %} {% set first = nav_item.children | first %}
{% set nav_item = nav_item.children | first %} {% if first.children %}
{% if nav_item.children %} {{ render(first, ref) }}
{% include "partials/tabs-item.html" %}
{% else %} {% else %}
<li class="md-tabs__item"> <li class="md-tabs__item">
<a href="{{ nav_item.url | url }}" class="{{ class }}"> <a href="{{ first.url | url }}" class="{{ class }}">
{{ title }} {{ render_content(first, ref) }}
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% else %} {% else %}
<li class="md-tabs__item"> <li class="md-tabs__item">
<a href="{{ nav_item.url | url }}" class="{{ class }}"> <a href="{{ nav_item.url | url }}" class="{{ class }}">
{{ nav_item.title }} {{ render_content(nav_item) }}
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% endmacro %}

View File

@ -1,12 +1,12 @@
{#- {#-
This file was automatically generated - do not edit This file was automatically generated - do not edit
-#} -#}
{% set class = "" %} {% import "partials/tabs-item.html" as item with context %}
<nav class="md-tabs" aria-label="{{ lang.t('tabs') }}" data-md-component="tabs"> <nav class="md-tabs" aria-label="{{ lang.t('tabs') }}" data-md-component="tabs">
<div class="md-grid"> <div class="md-grid">
<ul class="md-tabs__list"> <ul class="md-tabs__list">
{% for nav_item in nav %} {% for nav_item in nav %}
{% include "partials/tabs-item.html" %} {{ item.render(nav_item) }}
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>

View File

View File

@ -0,0 +1,82 @@
# Copyright (c) 2016-2023 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 functools import partial
from markdown.extensions.toc import slugify
from mkdocs.config.config_options import Choice, Deprecated, Optional, Type
from mkdocs.config.base import Config
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Blog plugin configuration scheme
class BlogConfig(Config):
enabled = Type(bool, default = True)
# Options for blog
blog_dir = Type(str, default = "blog")
blog_toc = Type(bool, default = False)
# Options for posts
post_date_format = Type(str, default = "long")
post_url_date_format = Type(str, default = "yyyy/MM/dd")
post_url_format = Type(str, default = "{date}/{slug}")
post_url_max_categories = Type(int, default = 1)
post_slugify = Type((type(slugify), partial), default = slugify)
post_slugify_separator = Type(str, default = "-")
post_excerpt = Choice(["optional", "required"], default = "optional")
post_excerpt_max_authors = Type(int, default = 1)
post_excerpt_max_categories = Type(int, default = 5)
post_excerpt_separator = Type(str, default = "<!-- more -->")
post_readtime = Type(bool, default = True)
post_readtime_words_per_minute = Type(int, default = 265)
# Options for archive
archive = Type(bool, default = True)
archive_name = Type(str, default = "blog.archive")
archive_date_format = Type(str, default = "yyyy")
archive_url_date_format = Type(str, default = "yyyy")
archive_url_format = Type(str, default = "archive/{date}")
archive_toc = Optional(Type(bool))
# Options for categories
categories = Type(bool, default = True)
categories_name = Type(str, default = "blog.categories")
categories_url_format = Type(str, default = "category/{slug}")
categories_slugify = Type((type(slugify), partial), default = slugify)
categories_slugify_separator = Type(str, default = "-")
categories_allowed = Type(list, default = [])
categories_toc = Optional(Type(bool))
# Options for pagination
pagination = Type(bool, default = True)
pagination_per_page = Type(int, default = 10)
pagination_url_format = Type(str, default = "page/{page}")
pagination_template = Type(str, default = "~2~")
# Options for authors
authors = Type(bool, default = True)
authors_file = Type(str, default = "{blog}/.authors.yml")
# Options for drafts
draft = Type(bool, default = False)
draft_on_serve = Type(bool, default = True)
draft_if_future_date = Type(bool, default = False)

View File

@ -0,0 +1,887 @@
# Copyright (c) 2016-2023 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 logging
import os
import paginate
import posixpath
import re
import readtime
import sys
from babel.dates import format_date
from copy import copy
from datetime import date, datetime, time
from hashlib import sha1
from lxml.html import fragment_fromstring, tostring
from mkdocs import utils
from mkdocs.utils.meta import get_data
from mkdocs.commands.build import _populate_page
from mkdocs.contrib.search import SearchIndex
from mkdocs.plugins import BasePlugin
from mkdocs.structure.files import File, Files
from mkdocs.structure.nav import Link, Section
from mkdocs.structure.pages import Page
from tempfile import gettempdir
from yaml import SafeLoader, load
from material.plugins.blog.config import BlogConfig
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Blog plugin
class BlogPlugin(BasePlugin[BlogConfig]):
supports_multiple_instances = True
# Determine whether we're running under dirty reload
def on_startup(self, *, command, dirty):
self.is_serve = (command == "serve")
self.is_dirtyreload = False
self.is_dirty = dirty
# Initialize plugin
def on_config(self, config):
if not self.config.enabled:
return
# Resolve source directory for posts and generated files
self.post_dir = self._resolve("posts")
self.temp_dir = gettempdir()
# Initialize posts
self.post_map = dict()
self.post_meta_map = dict()
self.post_pages = []
self.post_pager_pages = []
# Initialize archive
if self.config.archive:
self.archive_map = dict()
self.archive_post_map = dict()
# Initialize categories
if self.config.categories:
self.category_map = dict()
self.category_name_map = dict()
self.category_post_map = dict()
# Initialize authors
if self.config.authors:
self.authors_map = dict()
# Resolve authors file
path = os.path.normpath(os.path.join(
config.docs_dir,
self.config.authors_file.format(
blog = self.config.blog_dir
)
))
# Load authors map, if it exists
if os.path.isfile(path):
with open(path, encoding = "utf-8") as f:
self.authors_map = load(f, SafeLoader) or {}
# Ensure that format strings have no trailing slashes
for option in [
"post_url_format",
"archive_url_format",
"categories_url_format",
"pagination_url_format"
]:
if self.config[option].endswith("/"):
log.error(f"Option '{option}' must not contain trailing slash.")
sys.exit(1)
# Inherit global table of contents setting
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
# If pagination should not be used, set to large value
if not self.config.pagination:
self.config.pagination_per_page = 1e7
# By default, drafts are rendered when the documentation is served,
# but not when it is built. This should nicely align with the expected
# user experience when authoring documentation.
if self.is_serve and self.config.draft_on_serve:
self.config.draft = True
# Adjust paths to assets in the posts directory and preprocess posts
def on_files(self, files, *, config):
if not self.config.enabled:
return
# Adjust destination paths for assets
path = self._resolve("assets")
for file in files.media_files():
if self.post_dir not in file.src_uri:
continue
# Compute destination URL
file.url = file.url.replace(self.post_dir, path)
# Compute destination file system path
file.dest_uri = file.dest_uri.replace(self.post_dir, path)
file.abs_dest_path = os.path.join(config.site_dir, file.dest_path)
# Hack: as post URLs are dynamically computed and can be configured by
# the author, we need to compute them before we process the contents of
# any other page or post. If we wouldn't do that, URLs would be invalid
# and we would need to patch them afterwards. The only way to do this
# correctly is to first extract the metadata of all posts. Additionally,
# while we're at it, generate all archive and category pages as we have
# the post metadata on our hands. This ensures that we can safely link
# from anywhere to all pages that are generated as part of the blog.
for file in files.documentation_pages():
if self.post_dir not in file.src_uri:
continue
# Read and preprocess post
with open(file.abs_src_path, encoding = "utf-8") as f:
markdown, meta = get_data(f.read())
# Ensure post has a date set
if not meta.get("date"):
log.error(f"Blog post '{file.src_uri}' has no date set.")
sys.exit(1)
# Compute slug from metadata, content or file name
headline = utils.get_markdown_title(markdown)
slug = meta.get("title", headline or file.name)
# Front matter can be defind in YAML, guarded by two lines with
# `---` markers, or MultiMarkdown, separated by an empty line.
# If the author chooses to use MultiMarkdown syntax, date is
# returned as a string, which is different from YAML behavior,
# which returns a date. Thus, we must check for its type, and
# parse the date for normalization purposes.
if isinstance(meta["date"], str):
meta["date"] = date.fromisoformat(meta["date"])
# Normalize date to datetime for proper sorting
if not isinstance(meta["date"], datetime):
meta["date"] = datetime.combine(meta["date"], time())
# Compute category slugs
categories = []
for name in meta.get("categories", []):
categories.append(self.config.categories_slugify(
name, self.config.categories_slugify_separator
))
# Check if maximum number of categories is reached
max_categories = self.config.post_url_max_categories
if len(categories) == max_categories:
break
# Compute path from format string
date_format = self.config.post_url_date_format
path = self.config.post_url_format.format(
categories = "/".join(categories),
date = self._format_date(meta["date"], date_format, config),
file = file.name,
slug = meta.get("slug", self.config.post_slugify(
slug, self.config.post_slugify_separator
))
)
# Normalize path, as it may begin with a slash
path = posixpath.normpath("/".join([".", path]))
# Compute destination URL according to settings
file.url = self._resolve(path)
if not config.use_directory_urls:
file.url += ".html"
else:
file.url += "/"
# Compute destination file system path
file.dest_uri = re.sub(r"(?<=\/)$", "index.html", file.url)
file.abs_dest_path = os.path.join(
config.site_dir, file.dest_path
)
# Add post metadata
self.post_meta_map[file.src_uri] = meta
# Sort post metadata by date (descending)
self.post_meta_map = dict(sorted(
self.post_meta_map.items(),
key = lambda item: item[1]["date"], reverse = True
))
# Find and extract the section hosting the blog
path = self._resolve("index.md")
root = _host(config.nav, path)
# Ensure blog root exists
file = files.get_file_from_path(path)
if not file:
log.error(f"Blog root '{path}' does not exist.")
sys.exit(1)
# Ensure blog root is part of navigation
if not root:
log.error(f"Blog root '{path}' not in navigation.")
sys.exit(1)
# Generate and register files for archive
if self.config.archive:
name = self._translate(config, self.config.archive_name)
data = self._generate_files_for_archive(config, files)
if data:
root.append({ name: data })
# Generate and register files for categories
if self.config.categories:
name = self._translate(config, self.config.categories_name)
data = self._generate_files_for_categories(config, files)
if data:
root.append({ name: data })
# Hack: add posts temporarily, so MkDocs doesn't complain
name = sha1(path.encode("utf-8")).hexdigest()
root.append({
f"__posts_${name}": list(self.post_meta_map.keys())
})
# Cleanup navigation before proceeding
def on_nav(self, nav, *, config, files):
if not self.config.enabled:
return
# Find and resolve index for cleanup
path = self._resolve("index.md")
file = files.get_file_from_path(path)
# Determine blog root section
self.main = file.page
if self.main.parent:
root = self.main.parent.children
else:
root = nav.items
# Hack: remove temporarily added posts from the navigation
name = sha1(path.encode("utf-8")).hexdigest()
for item in root:
if not item.is_section or item.title != f"__posts_${name}":
continue
# Detach previous and next links of posts
if item.children:
head = item.children[+0]
tail = item.children[-1]
# Link page prior to posts to page after posts
if head.previous_page:
head.previous_page.next_page = tail.next_page
# Link page after posts to page prior to posts
if tail.next_page:
tail.next_page.previous_page = head.previous_page
# Contain previous and next links inside posts
head.previous_page = None
tail.next_page = None
# Set blog as parent page
for page in item.children:
page.parent = self.main
next = page.next_page
# Switch previous and next links
page.next_page = page.previous_page
page.previous_page = next
# Remove posts from navigation
root.remove(item)
break
# Prepare post for rendering
def on_page_markdown(self, markdown, *, page, config, files):
if not self.config.enabled:
return
# Only process posts
if self.post_dir not in page.file.src_uri:
return
# Skip processing of drafts
if self._is_draft(page.file.src_uri):
return
# Ensure template is set or use default
if "template" not in page.meta:
page.meta["template"] = "blog-post.html"
# Use previously normalized date
page.meta["date"] = self.post_meta_map[page.file.src_uri]["date"]
# Ensure navigation is hidden
page.meta["hide"] = page.meta.get("hide", [])
if "navigation" not in page.meta["hide"]:
page.meta["hide"].append("navigation")
# Format date for rendering
date_format = self.config.post_date_format
page.meta["date_format"] = self._format_date(
page.meta["date"], date_format, config
)
# Compute readtime if desired and not explicitly set
if self.config.post_readtime:
# There's a bug in the readtime library, which causes it to fail
# when the input string contains emojis (reported in #5555)
encoded = markdown.encode("unicode_escape")
if "readtime" not in page.meta:
rate = self.config.post_readtime_words_per_minute
read = readtime.of_markdown(encoded, rate)
page.meta["readtime"] = read.minutes
# Compute post categories
page.categories = []
if self.config.categories:
for name in page.meta.get("categories", []):
file = files.get_file_from_path(self.category_name_map[name])
page.categories.append(file.page)
# Compute post authors
page.authors = []
if self.config.authors:
for name in page.meta.get("authors", []):
if name not in self.authors_map:
log.error(
f"Blog post '{page.file.src_uri}' author '{name}' "
f"unknown, not listed in .authors.yml"
)
sys.exit(1)
# Add author to page
page.authors.append(self.authors_map[name])
# Fix stale link if previous post is a draft
prev = page.previous_page
while prev and self._is_draft(prev.file.src_uri):
page.previous_page = prev.previous_page
prev = prev.previous_page
# Fix stale link if next post is a draft
next = page.next_page
while next and self._is_draft(next.file.src_uri):
page.next_page = next.next_page
next = next.next_page
# Filter posts and generate excerpts for generated pages
def on_env(self, env, *, config, files):
if not self.config.enabled:
return
# Skip post excerpts on dirty reload to save time
if self.is_dirtyreload:
return
# Copy configuration and enable 'toc' extension
config = copy(config)
config.mdx_configs["toc"] = copy(config.mdx_configs.get("toc", {}))
# Ensure that post titles are links
config.mdx_configs["toc"]["anchorlink"] = True
config.mdx_configs["toc"]["permalink"] = False
# Filter posts that should not be published
for file in files.documentation_pages():
if self.post_dir in file.src_uri:
if self._is_draft(file.src_uri):
files.remove(file)
# Ensure template is set
if "template" not in self.main.meta:
self.main.meta["template"] = "blog.html"
# Populate archive
if self.config.archive:
for path in self.archive_map:
self.archive_post_map[path] = []
# Generate post excerpts for archive
base = files.get_file_from_path(path)
for file in self.archive_map[path]:
self.archive_post_map[path].append(
self._generate_excerpt(file, base, config, files)
)
# Ensure template is set
page = base.page
if "template" not in page.meta:
page.meta["template"] = "blog-archive.html"
# Populate categories
if self.config.categories:
for path in self.category_map:
self.category_post_map[path] = []
# Generate post excerpts for categories
base = files.get_file_from_path(path)
for file in self.category_map[path]:
self.category_post_map[path].append(
self._generate_excerpt(file, base, config, files)
)
# Ensure template is set
page = base.page
if "template" not in page.meta:
page.meta["template"] = "blog-category.html"
# Resolve path of initial index
curr = self._resolve("index.md")
base = self.main.file
# Initialize index
self.post_map[curr] = []
self.post_pager_pages.append(self.main)
# Generate indexes by paginating through posts
for path in self.post_meta_map.keys():
file = files.get_file_from_path(path)
if not self._is_draft(path):
self.post_pages.append(file.page)
else:
continue
# Generate new index when the current is full
per_page = self.config.pagination_per_page
if len(self.post_map[curr]) == per_page:
offset = 1 + len(self.post_map)
# Resolve path of new index
curr = self.config.pagination_url_format.format(page = offset)
curr = self._resolve(curr + ".md")
# Generate file
self._generate_file(curr, f"# {self.main.title}")
# Register file and page
base = self._register_file(curr, config, files)
page = self._register_page(base, config, files)
# Inherit page metadata, title and position
page.meta = self.main.meta
page.title = self.main.title
page.parent = self.main
page.previous_page = self.main.previous_page
page.next_page = self.main.next_page
# Initialize next index
self.post_map[curr] = []
self.post_pager_pages.append(page)
# Assign post excerpt to current index
self.post_map[curr].append(
self._generate_excerpt(file, base, config, files)
)
# Populate generated pages
def on_page_context(self, context, *, page, config, nav):
if not self.config.enabled:
return
# Provide post excerpts for index
path = page.file.src_uri
if path in self.post_map:
context["posts"] = self.post_map[path]
if self.config.blog_toc:
self._populate_toc(page, context["posts"])
# Create pagination
pagination = paginate.Page(
self.post_pages,
page = list(self.post_map.keys()).index(path) + 1,
items_per_page = self.config.pagination_per_page,
url_maker = lambda n: utils.get_relative_url(
self.post_pager_pages[n - 1].url,
page.url
)
)
# Create pagination pager
context["pagination"] = lambda args: pagination.pager(
format = self.config.pagination_template,
show_if_single_page = False,
**args
)
# Provide post excerpts for archive
if self.config.archive:
if path in self.archive_post_map:
context["posts"] = self.archive_post_map[path]
if self.config.archive_toc:
self._populate_toc(page, context["posts"])
# Provide post excerpts for categories
if self.config.categories:
if path in self.category_post_map:
context["posts"] = self.category_post_map[path]
if self.config.categories_toc:
self._populate_toc(page, context["posts"])
# Determine whether we're running under dirty reload
def on_serve(self, server, *, config, builder):
self.is_dirtyreload = self.is_dirty
# -------------------------------------------------------------------------
# Generate and register files for archive
def _generate_files_for_archive(self, config, files):
for path, meta in self.post_meta_map.items():
file = files.get_file_from_path(path)
if self._is_draft(path):
continue
# Compute name from format string
date_format = self.config.archive_date_format
name = self._format_date(meta["date"], date_format, config)
# Compute path from format string
date_format = self.config.archive_url_date_format
path = self.config.archive_url_format.format(
date = self._format_date(meta["date"], date_format, config)
)
# Create file for archive if it doesn't exist
path = self._resolve(path + ".md")
if path not in self.archive_map:
self.archive_map[path] = []
# Generate and register file for archive
self._generate_file(path, f"# {name}")
self._register_file(path, config, files)
# Assign current post to archive
self.archive_map[path].append(file)
# Return generated archive files
return list(self.archive_map.keys())
# Generate and register files for categories
def _generate_files_for_categories(self, config, files):
allowed = set(self.config.categories_allowed)
for path, meta in self.post_meta_map.items():
file = files.get_file_from_path(path)
if self._is_draft(path):
continue
# Ensure category is in (non-empty) allow list
categories = set(meta.get("categories", []))
if allowed:
for name in categories - allowed:
log.error(
f"Blog post '{file.src_uri}' uses a category "
f"which is not in allow list: {name}"
)
sys.exit(1)
# Traverse all categories of the post
for name in categories:
path = self.config.categories_url_format.format(
slug = self.config.categories_slugify(
name, self.config.categories_slugify_separator
)
)
# Create file for category if it doesn't exist
path = self._resolve(path + ".md")
if path not in self.category_map:
self.category_map[path] = []
# Generate and register file for category
self._generate_file(path, f"# {name}")
self._register_file(path, config, files)
# Link category path to name
self.category_name_map[name] = path
# Assign current post to category
self.category_map[path].append(file)
# Sort categories alphabetically (ascending)
self.category_map = dict(sorted(self.category_map.items()))
# Return generated category files
return list(self.category_map.keys())
# -------------------------------------------------------------------------
# Check if a post is a draft
def _is_draft(self, path):
meta = self.post_meta_map[path]
if not self.config.draft:
# Check if post date is in the future
future = False
if self.config.draft_if_future_date:
future = meta["date"] > datetime.now()
# Check if post is marked as draft
return meta.get("draft", future)
# Post is not a draft
return False
# Generate a post excerpt relative to base
def _generate_excerpt(self, file, base, config, files):
page = file.page
# Generate temporary file and page for post excerpt
temp = self._register_file(file.src_uri, config)
excerpt = Page(page.title, temp, config)
# Check for separator, if post excerpt is required
separator = self.config.post_excerpt_separator
if self.config.post_excerpt == "required":
if separator not in page.markdown:
log.error(f"Blog post '{temp.src_uri}' has no excerpt.")
sys.exit(1)
# Ensure separator at the end to strip footnotes and patch h1-h5
markdown = "\n\n".join([page.markdown, separator])
markdown = re.sub(r"(^#{1,5})", "#\\1", markdown, flags = re.MULTILINE)
# Extract content and metadata from original post
excerpt.file.url = base.url
excerpt.markdown = markdown
excerpt.meta = page.meta
# Render post and revert page URL
excerpt.render(config, files)
excerpt.file.url = page.url
# Find all anchor links
expr = re.compile(
r"<a[^>]+href=['\"]?#[^>]+>",
re.IGNORECASE | re.MULTILINE
)
# Replace callback
first = True
def replace(match):
value = match.group()
# Handle anchor link
el = fragment_fromstring(value.encode("utf-8"))
if el.tag == "a":
nonlocal first
# Fix up each anchor link of the excerpt with a link to the
# anchor of the actual post, except for the first one that
# one needs to go to the top of the post. A better way might
# be a Markdown extension, but for now this should be fine.
url = utils.get_relative_url(excerpt.file.url, base.url)
if first:
el.set("href", url)
else:
el.set("href", url + el.get("href"))
# From now on reference anchors
first = False
# Replace link opening tag (without closing tag)
return tostring(el, encoding = "unicode")[:-4]
# Extract excerpt from post and replace anchor links
excerpt.content = expr.sub(
replace,
excerpt.content.split(separator)[0]
)
# Determine maximum number of authors and categories
max_authors = self.config.post_excerpt_max_authors
max_categories = self.config.post_excerpt_max_categories
# Obtain computed metadata from original post
excerpt.authors = page.authors[:max_authors]
excerpt.categories = page.categories[:max_categories]
# Return post excerpt
return excerpt
# Generate a file with the given template and content
def _generate_file(self, path, content):
content = f"---\nsearch:\n exclude: true\n---\n\n{content}"
utils.write_file(
bytes(content, "utf-8"),
os.path.join(self.temp_dir, path)
)
# Register a file
def _register_file(self, path, config, files = Files([])):
file = files.get_file_from_path(path)
if not file:
urls = config.use_directory_urls
file = File(path, self.temp_dir, config.site_dir, urls)
files.append(file)
# Mark file as generated, so other plugins don't think it's part
# of the file system. This is more or less a new quasi-standard
# for plugins that generate files which was introduced by the
# git-revision-date-localized-plugin - see https://bit.ly/3ZUmdBx
file.generated_by = "material/blog"
# Return file
return file
# Register and populate a page
def _register_page(self, file, config, files):
page = Page(None, file, config)
_populate_page(page, config, files)
return page
# Populate table of contents of given page
def _populate_toc(self, page, posts):
toc = page.toc.items[0]
for post in posts:
toc.children.append(post.toc.items[0])
# Remove anchors below the second level
post.toc.items[0].children = []
# Translate the given placeholder value
def _translate(self, config, value):
env = config.theme.get_env()
# Load language template and return translation for placeholder
language = "partials/language.html"
template = env.get_template(language, None, { "config": config })
return template.module.t(value)
# Resolve path relative to blog root
def _resolve(self, *args):
path = posixpath.join(self.config.blog_dir, *args)
return posixpath.normpath(path)
# Format date according to locale
def _format_date(self, date, format, config):
return format_date(
date,
format = format,
locale = config.theme["language"]
)
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
# Search the given navigation section (from the configuration) recursively to
# find the section to host all generated pages (archive, categories, etc.)
def _host(nav, path):
# Search navigation dictionary
if isinstance(nav, dict):
for _, item in nav.items():
result = _host(item, path)
if result:
return result
# Search navigation list
elif isinstance(nav, list):
if path in nav:
return nav
# Search each list item
for item in nav:
if isinstance(item, dict) and path in item.values():
if path in item.values():
return nav
else:
result = _host(item, path)
if result:
return result
# Copied and adapted from MkDocs, because we need to return existing pages and
# support anchor names as subtitles, which is pretty fucking cool.
def _data_to_navigation(nav, config, files):
# Search navigation dictionary
if isinstance(nav, dict):
return [
_data_to_navigation((key, value), config, files)
if isinstance(value, str) else
Section(
title = key,
children = _data_to_navigation(value, config, files)
)
for key, value in nav.items()
]
# Search navigation list
elif isinstance(nav, list):
return [
_data_to_navigation(item, config, files)[0]
if isinstance(item, dict) and len(item) == 1 else
_data_to_navigation(item, config, files)
for item in nav
]
# Extract navigation title and path and split anchors
title, path = nav if isinstance(nav, tuple) else (None, nav)
path, _, anchor = path.partition("#")
# Try to retrieve existing file
file = files.get_file_from_path(path)
if not file:
return Link(title, path)
# Use resolved assets destination path
if not path.endswith(".md"):
return Link(title or os.path.basename(path), file.url)
# Generate temporary file as for post excerpts
else:
urls = config.use_directory_urls
link = File(path, config.docs_dir, config.site_dir, urls)
page = Page(title or file.page.title, link, config)
# Set destination file system path and URL from original file
link.dest_uri = file.dest_uri
link.abs_dest_path = file.abs_dest_path
link.url = file.url
# Retrieve name of anchor by misusing the search index
if anchor:
item = SearchIndex()._find_toc_by_id(file.page.toc, anchor)
# Set anchor name as subtitle
page.meta["subtitle"] = item.title
link.url += f"#{anchor}"
# Return navigation item
return page
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.blog")

View File

@ -0,0 +1,36 @@
# Copyright (c) 2016-2023 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
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Info plugin configuration scheme
class InfoConfig(Config):
enabled = Type(bool, default = True)
enabled_on_serve = Type(bool, default = False)
# Options for archive
archive = Type(bool, default = True)
archive_name = Type(str, default = "example")
archive_stop_on_violation = Type(bool, default = True)

View File

@ -28,32 +28,19 @@ import sys
from colorama import Fore, Style from colorama import Fore, Style
from io import BytesIO from io import BytesIO
from mkdocs import utils from mkdocs import utils
from mkdocs.commands.build import DuplicateFilter
from mkdocs.config import config_options as opt
from mkdocs.config.base import Config
from mkdocs.plugins import BasePlugin, event_priority from mkdocs.plugins import BasePlugin, event_priority
from mkdocs.structure.files import get_files from mkdocs.structure.files import get_files
from pkg_resources import get_distribution, working_set from pkg_resources import get_distribution, working_set
from zipfile import ZipFile, ZIP_DEFLATED from zipfile import ZipFile, ZIP_DEFLATED
from material.plugins.info.config import InfoConfig
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Class # Class
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Info plugin configuration scheme
class InfoPluginConfig(Config):
enabled = opt.Type(bool, default = True)
enabled_on_serve = opt.Type(bool, default = False)
# Options for archive
archive = opt.Type(bool, default = True)
archive_name = opt.Type(str, default = "example")
archive_stop_on_violation = opt.Type(bool, default = True)
# -----------------------------------------------------------------------------
# Info plugin # Info plugin
class InfoPlugin(BasePlugin[InfoPluginConfig]): class InfoPlugin(BasePlugin[InfoConfig]):
# Determine whether we're serving # Determine whether we're serving
def on_startup(self, *, command, dirty): def on_startup(self, *, command, dirty):
@ -235,5 +222,4 @@ def _size(value, factor = 1):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Set up logging # Set up logging
log = logging.getLogger("mkdocs") log = logging.getLogger("mkdocs.material.info")
log.addFilter(DuplicateFilter())

View File

@ -0,0 +1,30 @@
# Copyright (c) 2016-2023 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
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Offline plugin configuration scheme
class OfflineConfig(Config):
enabled = Type(bool, default = True)

View File

@ -21,22 +21,16 @@
import os import os
from mkdocs import utils from mkdocs import utils
from mkdocs.config import config_options as opt
from mkdocs.config.base import Config
from mkdocs.plugins import BasePlugin, event_priority from mkdocs.plugins import BasePlugin, event_priority
from material.plugins.offline.config import OfflineConfig
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Class # Class
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Offline plugin configuration scheme
class OfflinePluginConfig(Config):
enabled = opt.Type(bool, default = True)
# -----------------------------------------------------------------------------
# Offline plugin # Offline plugin
class OfflinePlugin(BasePlugin[OfflinePluginConfig]): class OfflinePlugin(BasePlugin[OfflineConfig]):
# Initialize plugin # Initialize plugin
def on_config(self, config): def on_config(self, config):

View File

@ -0,0 +1,51 @@
# Copyright (c) 2016-2023 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 (
Choice,
Deprecated,
Optional,
ListOfItems,
Type
)
from mkdocs.config.base import Config
from mkdocs.contrib.search import LangOption
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Search pipeline functions
pipeline = ("stemmer", "stopWordFilter", "trimmer")
# Search plugin configuration scheme
class SearchConfig(Config):
lang = Optional(LangOption())
separator = Optional(Type(str))
pipeline = ListOfItems(Choice(pipeline), default = [])
# Options for text segmentation (Chinese)
jieba_dict = Optional(Type(str))
jieba_dict_user = Optional(Type(str))
# Unsupported options, originally implemented in MkDocs
indexing = Deprecated(message = "Unsupported option")
prebuild_index = Deprecated(message = "Unsupported option")
min_search_length = Deprecated(message = "Unsupported option")

View File

@ -26,34 +26,22 @@ import regex as re
from html import escape from html import escape
from html.parser import HTMLParser from html.parser import HTMLParser
from mkdocs import utils from mkdocs import utils
from mkdocs.commands.build import DuplicateFilter from mkdocs.config.config_options import SubConfig
from mkdocs.config import config_options as opt
from mkdocs.config.base import Config
from mkdocs.contrib.search import LangOption
from mkdocs.plugins import BasePlugin from mkdocs.plugins import BasePlugin
from material.plugins.search.config import SearchConfig, SearchFieldConfig
try:
import jieba
except ImportError:
jieba = None
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Class # Class
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Search plugin configuration scheme
class SearchPluginConfig(Config):
lang = opt.Optional(LangOption())
separator = opt.Optional(opt.Type(str))
pipeline = opt.ListOfItems(
opt.Choice(("stemmer", "stopWordFilter", "trimmer")),
default = []
)
# Deprecated options
indexing = opt.Deprecated(message = "Unsupported option")
prebuild_index = opt.Deprecated(message = "Unsupported option")
min_search_length = opt.Deprecated(message = "Unsupported option")
# -----------------------------------------------------------------------------
# Search plugin # Search plugin
class SearchPlugin(BasePlugin[SearchPluginConfig]): class SearchPlugin(BasePlugin[SearchConfig]):
# Determine whether we're running under dirty reload # Determine whether we're running under dirty reload
def on_startup(self, *, command, dirty): def on_startup(self, *, command, dirty):
@ -85,6 +73,30 @@ class SearchPlugin(BasePlugin[SearchPluginConfig]):
# Initialize search index # Initialize search index
self.search_index = SearchIndex(**self.config) self.search_index = SearchIndex(**self.config)
# Set jieba dictionary, if given
if self.config.jieba_dict:
path = os.path.normpath(self.config.jieba_dict)
if os.path.exists(path):
jieba.set_dictionary(path)
log.debug(f"Loading jieba dictionary: {path}")
else:
log.warning(
f"Configuration error for 'search.jieba_dict': "
f"'{self.config.jieba_dict}' does not exist."
)
# Set jieba user dictionary, if given
if self.config.jieba_dict_user:
path = os.path.normpath(self.config.jieba_dict_user)
if os.path.exists(path):
jieba.load_userdict(path)
log.debug(f"Loading jieba user dictionary: {path}")
else:
log.warning(
f"Configuration error for 'search.jieba_dict_user': "
f"'{self.config.jieba_dict_user}' does not exist."
)
# Add page to search index # Add page to search index
def on_page_context(self, context, *, page, config, nav): def on_page_context(self, context, *, page, config, nav):
self.search_index.add_entry_from_context(page) self.search_index.add_entry_from_context(page)
@ -167,9 +179,10 @@ class SearchIndex:
title = "".join(section.title).strip() title = "".join(section.title).strip()
text = "".join(section.text).strip() text = "".join(section.text).strip()
# Reset text, if only titles should be indexed # Segment Chinese characters if jieba is available
if self.config["indexing"] == "titles": if jieba:
text = "" title = self._segment_chinese(title)
text = self._segment_chinese(text)
# Create entry for section # Create entry for section
entry = { entry = {
@ -252,6 +265,25 @@ class SearchIndex:
# No item found # No item found
return None return None
# Find and segment Chinese characters in string
def _segment_chinese(self, data):
expr = re.compile(r"(\p{IsHan}+)", re.UNICODE)
# Replace callback
def replace(match):
value = match.group(0)
# Replace occurrence in original string with segmented version and
# surround with zero-width whitespace for efficient indexing
return "".join([
"\u200b",
"\u200b".join(jieba.cut(value.encode("utf-8"))),
"\u200b",
])
# Return string with segmented occurrences
return expr.sub(replace, data).strip("\u200b")
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# HTML element # HTML element
@ -341,7 +373,8 @@ class Parser(HTMLParser):
self.keep = set([ self.keep = set([
"p", # Paragraphs "p", # Paragraphs
"code", "pre", # Code blocks "code", "pre", # Code blocks
"li", "ol", "ul" # Lists "li", "ol", "ul", # Lists
"sub", "sup" # Sub- and superscripts
]) ])
# Current context and section # Current context and section
@ -362,7 +395,7 @@ class Parser(HTMLParser):
else: else:
return return
# Handle headings # Handle heading
if tag in ([f"h{x}" for x in range(1, 7)]): if tag in ([f"h{x}" for x in range(1, 7)]):
depth = len(self.context) depth = len(self.context)
if "id" in attrs: if "id" in attrs:
@ -507,8 +540,7 @@ class Parser(HTMLParser):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Set up logging # Set up logging
log = logging.getLogger("mkdocs") log = logging.getLogger("mkdocs.material.search")
log.addFilter(DuplicateFilter())
# Tags that are self-closing # Tags that are self-closing
void = set([ void = set([

View File

@ -0,0 +1,33 @@
# Copyright (c) 2016-2023 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 logging
import sys
try:
import cairosvg as _
import PIL as _
except ImportError:
log = logging.getLogger("mkdocs.material.social")
log.error(
"Required dependencies of \"social\" plugin not found. "
"Install with: pip install pillow cairosvg"
)
sys.exit(1)

View File

@ -0,0 +1,48 @@
# Copyright (c) 2016-2023 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.base import Config
from mkdocs.config.config_options import Deprecated, Type
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Social plugin configuration scheme
class SocialConfig(Config):
enabled = Type(bool, default = True)
cache_dir = Type(str, default = ".cache/plugin/social")
# Options for social cards
cards = Type(bool, default = True)
cards_dir = Type(str, default = "assets/images/social")
cards_layout_options = Type(dict, default = {})
# Deprecated options
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'"
)

View File

@ -25,56 +25,26 @@ import os
import posixpath import posixpath
import re import re
import requests import requests
import sys
from cairosvg import svg2png
from collections import defaultdict from collections import defaultdict
from hashlib import md5 from hashlib import md5
from io import BytesIO from io import BytesIO
from mkdocs.commands.build import DuplicateFilter from mkdocs.commands.build import DuplicateFilter
from mkdocs.config import config_options as opt
from mkdocs.config.base import Config
from mkdocs.plugins import BasePlugin from mkdocs.plugins import BasePlugin
from PIL import Image, ImageDraw, ImageFont
from shutil import copyfile from shutil import copyfile
from tempfile import TemporaryFile from tempfile import TemporaryFile
from zipfile import ZipFile from zipfile import ZipFile
try: from material.plugins.social.config import SocialConfig
from cairosvg import svg2png
from PIL import Image, ImageDraw, ImageFont
dependencies = True
except ImportError:
dependencies = False
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Class # Class
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Social plugin configuration scheme
class SocialPluginConfig(Config):
enabled = opt.Type(bool, default = True)
cache_dir = opt.Type(str, default = ".cache/plugin/social")
# Options for social cards
cards = opt.Type(bool, default = True)
cards_dir = opt.Type(str, default = "assets/images/social")
cards_layout_options = opt.Type(dict, default = {})
# Deprecated options
cards_color = opt.Deprecated(
option_type = opt.Type(dict, default = {}),
message =
"Deprecated, use 'cards_layout_options.background_color' "
"and 'cards_layout_options.color' with 'default' layout"
)
cards_font = opt.Deprecated(
option_type = opt.Type(str),
message = "Deprecated, use 'cards_layout_options.font_family'"
)
# -----------------------------------------------------------------------------
# Social plugin # Social plugin
class SocialPlugin(BasePlugin[SocialPluginConfig]): class SocialPlugin(BasePlugin[SocialConfig]):
def __init__(self): def __init__(self):
self._executor = concurrent.futures.ThreadPoolExecutor(4) self._executor = concurrent.futures.ThreadPoolExecutor(4)
@ -104,14 +74,6 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]):
value = self.config.cards_font value = self.config.cards_font
self.config.cards_layout_options["font_family"] = value self.config.cards_layout_options["font_family"] = value
# Check if required dependencies are installed
if not dependencies:
log.error(
"Required dependencies of \"social\" plugin not found. "
"Install with: pip install pillow cairosvg"
)
sys.exit(1)
# Check if site URL is defined # Check if site URL is defined
if not config.site_url: if not config.site_url:
log.warning( log.warning(

View File

@ -0,0 +1,27 @@
# Copyright (c) 2016-2023 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.
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Casefold a string for comparison when sorting
def casefold(tag: str):
return tag.casefold()

View File

@ -0,0 +1,43 @@
# Copyright (c) 2016-2023 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 functools import partial
from markdown.extensions.toc import slugify
from mkdocs.config.config_options import Optional, Type
from mkdocs.config.base import Config
from material.plugins.tags import casefold
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Tags plugin configuration scheme
class TagsConfig(Config):
enabled = Type(bool, default = True)
# Options for tags
tags_file = Optional(Type(str))
tags_extra_files = Type(dict, default = dict())
tags_slugify = Type((type(slugify), partial), default = slugify)
tags_slugify_separator = Type(str, default = "-")
tags_compare = Optional(Type(type(casefold)))
tags_compare_reverse = Type(bool, default = False)
tags_allowed = Type(list, default = [])

View File

@ -24,26 +24,19 @@ import sys
from collections import defaultdict from collections import defaultdict
from markdown.extensions.toc import slugify from markdown.extensions.toc import slugify
from mkdocs import utils from mkdocs import utils
from mkdocs.commands.build import DuplicateFilter
from mkdocs.config.base import Config
from mkdocs.config import config_options as opt
from mkdocs.plugins import BasePlugin from mkdocs.plugins import BasePlugin
# deprecated, but kept for downward compatibility. Use 'material.plugins.tags'
# as an import source instead. This import is removed in the next major version.
from material.plugins.tags import casefold
from material.plugins.tags.config import TagsConfig
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Class # Class
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Tags plugin configuration scheme
class TagsPluginConfig(Config):
enabled = opt.Type(bool, default = True)
# Options for tags
tags_file = opt.Optional(opt.Type(str))
# -----------------------------------------------------------------------------
# Tags plugin # Tags plugin
class TagsPlugin(BasePlugin[TagsPluginConfig]): class TagsPlugin(BasePlugin[TagsConfig]):
supports_multiple_instances = True supports_multiple_instances = True
# Initialize plugin # Initialize plugin
@ -166,5 +159,4 @@ class TagsPlugin(BasePlugin[TagsPluginConfig]):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Set up logging # Set up logging
log = logging.getLogger("mkdocs") log = logging.getLogger("mkdocs.material.tags")
log.addFilter(DuplicateFilter())

View File

@ -27,11 +27,38 @@ import {
fromEvent, fromEvent,
map, map,
merge, merge,
shareReplay,
startWith startWith
} from "rxjs" } from "rxjs"
import { getActiveElement } from "../_" import { getActiveElement } from "../_"
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Focus observable
*
* Previously, this observer used `focus` and `blur` events to determine whether
* an element is focused, but this doesn't work if there are focusable elements
* within the elements itself. A better solutions are `focusin` and `focusout`
* events, which bubble up the tree and allow for more fine-grained control.
*
* `debounceTime` is necessary, because when a focus change happens inside an
* element, the observable would first emit `false` and then `true` again.
*/
const observer$ = merge(
fromEvent(document.body, "focusin"),
fromEvent(document.body, "focusout")
)
.pipe(
debounceTime(1),
startWith(undefined),
map(() => getActiveElement() || document.body),
shareReplay(1)
)
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Functions * Functions
* ------------------------------------------------------------------------- */ * ------------------------------------------------------------------------- */
@ -39,14 +66,6 @@ import { getActiveElement } from "../_"
/** /**
* Watch element focus * Watch element focus
* *
* Previously, this function used `focus` and `blur` events to determine whether
* an element is focused, but this doesn't work if there are focusable elements
* within the elements itself. A better solutions are `focusin` and `focusout`
* events, which bubble up the tree and allow for more fine-grained control.
*
* `debounceTime` is necessary, because when a focus change happens inside an
* element, the observable would first emit `false` and then `true` again.
*
* @param el - Element * @param el - Element
* *
* @returns Element focus observable * @returns Element focus observable
@ -54,19 +73,9 @@ import { getActiveElement } from "../_"
export function watchElementFocus( export function watchElementFocus(
el: HTMLElement el: HTMLElement
): Observable<boolean> { ): Observable<boolean> {
return merge( return observer$
fromEvent(document.body, "focusin"),
fromEvent(document.body, "focusout")
)
.pipe( .pipe(
debounceTime(1), map(active => el.contains(active)),
map(() => {
const active = getActiveElement()
return typeof active !== "undefined"
? el.contains(active)
: false
}),
startWith(el === getActiveElement()),
distinctUntilChanged() distinctUntilChanged()
) )
} }

View File

@ -43,7 +43,7 @@ import { h } from "~/utilities"
* @returns Location hash * @returns Location hash
*/ */
export function getLocationHash(): string { export function getLocationHash(): string {
return location.hash.substring(1) return location.hash.slice(1)
} }
/** /**

View File

@ -20,7 +20,6 @@
* IN THE SOFTWARE. * IN THE SOFTWARE.
*/ */
import "iframe-worker/shim"
import { import {
Observable, Observable,
Subject, Subject,

View File

@ -22,6 +22,7 @@
import "array-flat-polyfill" import "array-flat-polyfill"
import "focus-visible" import "focus-visible"
import "iframe-worker/shim"
import "unfetch/polyfill" import "unfetch/polyfill"
import "url-polyfill" import "url-polyfill"

View File

@ -22,20 +22,26 @@
import { Observable, merge } from "rxjs" import { Observable, merge } from "rxjs"
import { feature } from "~/_"
import { Viewport, getElements } from "~/browser" import { Viewport, getElements } from "~/browser"
import { Component } from "../../_" import { Component } from "../../_"
import { Annotation } from "../annotation" import {
Annotation,
mountAnnotationBlock
} from "../annotation"
import { import {
CodeBlock, CodeBlock,
Mermaid, mountCodeBlock
mountCodeBlock,
mountMermaid
} from "../code" } from "../code"
import { import {
Details, Details,
mountDetails mountDetails
} from "../details" } from "../details"
import {
Mermaid,
mountMermaid
} from "../mermaid"
import { import {
DataTable, DataTable,
mountDataTable mountDataTable
@ -54,11 +60,11 @@ import {
*/ */
export type Content = export type Content =
| Annotation | Annotation
| ContentTabs
| CodeBlock | CodeBlock
| Mermaid | ContentTabs
| DataTable | DataTable
| Details | Details
| Mermaid
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Helper types * Helper types
@ -93,6 +99,10 @@ export function mountContent(
): Observable<Component<Content>> { ): Observable<Component<Content>> {
return merge( return merge(
/* Annotations */
...getElements(".annotate:not(.highlight)", el)
.map(child => mountAnnotationBlock(child, { target$, print$ })),
/* Code blocks */ /* Code blocks */
...getElements("pre:not(.mermaid) > code", el) ...getElements("pre:not(.mermaid) > code", el)
.map(child => mountCodeBlock(child, { target$, print$ })), .map(child => mountCodeBlock(child, { target$, print$ })),

View File

@ -0,0 +1,88 @@
/*
* Copyright (c) 2016-2023 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 { EMPTY, Observable, defer } from "rxjs"
import { Component } from "../../../_"
import { Annotation } from "../_"
import { mountAnnotationList } from "../list"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
target$: Observable<HTMLElement> /* Location target observable */
print$: Observable<boolean> /* Media print observable */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Find list element directly following a block
*
* @param el - Annotation block element
*
* @returns List element or nothing
*/
function findList(el: HTMLElement): HTMLElement | undefined {
if (el.nextElementSibling) {
const sibling = el.nextElementSibling as HTMLElement
if (sibling.tagName === "OL")
return sibling
/* Skip empty paragraphs - see https://bit.ly/3r4ZJ2O */
else if (sibling.tagName === "P" && !sibling.children.length)
return findList(sibling)
}
/* Everything else */
return undefined
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount annotation block
*
* @param el - Annotation block element
* @param options - Options
*
* @returns Annotation component observable
*/
export function mountAnnotationBlock(
el: HTMLElement, options: MountOptions
): Observable<Component<Annotation>> {
return defer(() => {
const list = findList(el)
return typeof list !== "undefined"
? mountAnnotationList(list, el, options)
: EMPTY
})
}

View File

@ -21,4 +21,5 @@
*/ */
export * from "./_" export * from "./_"
export * from "./block"
export * from "./list" export * from "./list"

View File

@ -63,15 +63,28 @@ interface MountOptions {
* ------------------------------------------------------------------------- */ * ------------------------------------------------------------------------- */
/** /**
* Find all annotation markers in the given code block * Find all annotation hosts in the containing element
*
* @param container - Containing element
*
* @returns Annotation hosts
*/
function findHosts(container: HTMLElement): HTMLElement[] {
return container.tagName === "CODE"
? getElements(".c, .c1, .cm", container)
: [container]
}
/**
* Find all annotation markers in the containing element
* *
* @param container - Containing element * @param container - Containing element
* *
* @returns Annotation markers * @returns Annotation markers
*/ */
function findAnnotationMarkers(container: HTMLElement): Text[] { function findMarkers(container: HTMLElement): Text[] {
const markers: Text[] = [] const markers: Text[] = []
for (const el of getElements(".c, .c1, .cm", container)) { for (const el of findHosts(container)) {
const nodes: Text[] = [] const nodes: Text[] = []
/* Find all text nodes in current element */ /* Find all text nodes in current element */
@ -141,7 +154,7 @@ export function mountAnnotationList(
/* Find and replace all markers with empty annotations */ /* Find and replace all markers with empty annotations */
const annotations = new Map<string, HTMLElement>() const annotations = new Map<string, HTMLElement>()
for (const marker of findAnnotationMarkers(container)) { for (const marker of findMarkers(container)) {
const [, id] = marker.textContent!.match(/\((\d+)\)/)! const [, id] = marker.textContent!.match(/\((\d+)\)/)!
if (getOptionalElement(`:scope > li:nth-child(${id})`, el)) { if (getOptionalElement(`:scope > li:nth-child(${id})`, el)) {
annotations.set(id, renderAnnotation(id, prefix)) annotations.set(id, renderAnnotation(id, prefix))
@ -155,7 +168,8 @@ export function mountAnnotationList(
/* Mount component on subscription */ /* Mount component on subscription */
return defer(() => { return defer(() => {
const done$ = new Subject() const push$ = new Subject()
const done$ = push$.pipe(ignoreElements(), endWith(true))
/* Retrieve container pairs for swapping */ /* Retrieve container pairs for swapping */
const pairs: [HTMLElement, HTMLElement][] = [] const pairs: [HTMLElement, HTMLElement][] = []
@ -166,13 +180,13 @@ export function mountAnnotationList(
]) ])
/* Handle print mode - see https://bit.ly/3rgPdpt */ /* Handle print mode - see https://bit.ly/3rgPdpt */
print$ print$.pipe(takeUntil(done$))
.pipe(
takeUntil(done$.pipe(ignoreElements(), endWith(true)))
)
.subscribe(active => { .subscribe(active => {
el.hidden = !active el.hidden = !active
/* Add class to discern list element */
el.classList.toggle("md-annotation-list", active)
/* Show annotations in code block or list (print) */ /* Show annotations in code block or list (print) */
for (const [inner, child] of pairs) for (const [inner, child] of pairs)
if (!active) if (!active)
@ -188,7 +202,7 @@ export function mountAnnotationList(
)) ))
) )
.pipe( .pipe(
finalize(() => done$.complete()), finalize(() => push$.complete()),
share() share()
) )
}) })

View File

@ -21,4 +21,3 @@
*/ */
export * from "./_" export * from "./_"
export * from "./mermaid"

View File

@ -31,7 +31,7 @@ import {
import { watchScript } from "~/browser" import { watchScript } from "~/browser"
import { h } from "~/utilities" import { h } from "~/utilities"
import { Component } from "../../../_" import { Component } from "../../_"
import themeCSS from "./index.css" import themeCSS from "./index.css"

View File

@ -129,7 +129,7 @@ export function mountHeaderTitle(
}) })
/* Obtain headline, if any */ /* Obtain headline, if any */
const heading = getOptionalElement("article h1") const heading = getOptionalElement(".md-content h1")
if (typeof heading === "undefined") if (typeof heading === "undefined")
return EMPTY return EMPTY

View File

@ -107,7 +107,7 @@ interface MountOptions {
export function watchSidebar( export function watchSidebar(
el: HTMLElement, { viewport$, main$ }: WatchOptions el: HTMLElement, { viewport$, main$ }: WatchOptions
): Observable<Sidebar> { ): Observable<Sidebar> {
const parent = el.parentElement! const parent = el.closest<HTMLElement>(".md-grid")!
const adjust = const adjust =
parent.offsetTop - parent.offsetTop -
parent.parentElement!.offsetTop parent.parentElement!.offsetTop

View File

@ -110,7 +110,7 @@ export function setupInstantLoading(
return EMPTY return EMPTY
// Skip, as target is not within a link - clicks on non-link elements // Skip, as target is not within a link - clicks on non-link elements
// are also captured, which we need to exclude from processing. // are also captured, which we need to exclude from processing
const el = ev.target.closest("a") const el = ev.target.closest("a")
if (el === null) if (el === null)
return EMPTY return EMPTY
@ -151,7 +151,7 @@ export function setupInstantLoading(
) )
// Before fetching for the first time, resolve the absolute favicon position, // Before fetching for the first time, resolve the absolute favicon position,
// as the browser will try to fetch the icon immediately. // as the browser will try to fetch the icon immediately
instant$.pipe(take(1)) instant$.pipe(take(1))
.subscribe(() => { .subscribe(() => {
const favicon = getOptionalElement<HTMLLinkElement>("link[rel=icon]") const favicon = getOptionalElement<HTMLLinkElement>("link[rel=icon]")
@ -216,7 +216,7 @@ export function setupInstantLoading(
) )
// Initialize the DOM parser, parse the returned HTML, and replace selected // Initialize the DOM parser, parse the returned HTML, and replace selected
// meta tags and components before handing control down to the application. // meta tags and components before handing control down to the application
const dom = new DOMParser() const dom = new DOMParser()
const document$ = response$ const document$ = response$
.pipe( .pipe(
@ -253,7 +253,7 @@ export function setupInstantLoading(
} }
// After meta tags and components were replaced, re-evaluate scripts // After meta tags and components were replaced, re-evaluate scripts
// that were provided by the author as part of Markdown files. // that were provided by the author as part of Markdown files
const container = getComponentElement("container") const container = getComponentElement("container")
return concat(getElements("script", container)) return concat(getElements("script", container))
.pipe( .pipe(
@ -284,7 +284,7 @@ export function setupInstantLoading(
) )
// Intercept popstate events, e.g. when using the browser's back and forward // Intercept popstate events, e.g. when using the browser's back and forward
// buttons, and emit new location for fetching and parsing. // buttons, and emit new location for fetching and parsing
const popstate$ = fromEvent<PopStateEvent>(window, "popstate") const popstate$ = fromEvent<PopStateEvent>(window, "popstate")
popstate$.pipe(map(getLocation)) popstate$.pipe(map(getLocation))
.subscribe(location$) .subscribe(location$)

View File

@ -37,6 +37,7 @@ import {
SearchQueryTerms, SearchQueryTerms,
getSearchQueryTerms, getSearchQueryTerms,
parseSearchQuery, parseSearchQuery,
segment,
transformSearchQuery transformSearchQuery
} from "../query" } from "../query"
@ -204,6 +205,14 @@ export class Search {
* @returns Search result * @returns Search result
*/ */
public search(query: string): SearchResult { public search(query: string): SearchResult {
// Experimental Chinese segmentation
query = query.replace(/\p{sc=Han}+/gu, value => {
return [...segment(value, this.index.invertedIndex)]
.join("* ")
})
// @todo: move segmenter (above) into transformSearchQuery
query = transformSearchQuery(query) query = transformSearchQuery(query)
if (!query) if (!query)
return { items: [] } return { items: [] }

View File

@ -21,4 +21,5 @@
*/ */
export * from "./_" export * from "./_"
export * from "./segment"
export * from "./transform" export * from "./transform"

View File

@ -0,0 +1,81 @@
/*
* Copyright (c) 2016-2023 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.
*/
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Segment a search query using the inverted index
*
* This function implements a clever approach to text segmentation for Asian
* languages, as it used the information already available in the search index.
* The idea is to greedily segment the search query based on the tokens that are
* already part of the index, as described in the linked issue.
*
* @see https://bit.ly/3lwjrk7 - GitHub issue
*
* @param query - Query value
* @param index - Inverted index
*
* @returns Segmented query value
*/
export function segment(
query: string, index: object
): Iterable<string> {
const segments = new Set<string>()
/* Segment search query */
const wordcuts = new Uint16Array(query.length)
for (let i = 0; i < query.length; i++)
for (let j = i + 1; j < query.length; j++) {
const value = query.slice(i, j)
if (value in index)
wordcuts[i] = j - i
}
/* Compute longest matches with minimum overlap */
const stack = [0]
for (let s = stack.length; s > 0;) {
const p = stack[--s]
for (let q = 1; q < wordcuts[p]; q++)
if (wordcuts[p + q] > wordcuts[p] - q) {
segments.add(query.slice(p, p + q))
stack[s++] = p + q
}
/* Continue at end of query string */
const q = p + wordcuts[p]
if (wordcuts[q] && q < query.length - 1)
stack[s++] = q
/* Add current segment */
segments.add(query.slice(p, q))
}
// @todo fix this case in the code block above, this is a hotfix
if (segments.has(""))
return new Set([query])
/* Return segmented query value */
return segments
}

View File

@ -41,6 +41,7 @@
@import "main/icons"; @import "main/icons";
@import "main/typeset"; @import "main/typeset";
@import "main/components/author";
@import "main/components/banner"; @import "main/components/banner";
@import "main/components/base"; @import "main/components/base";
@import "main/components/clipboard"; @import "main/components/clipboard";
@ -51,11 +52,15 @@
@import "main/components/footer"; @import "main/components/footer";
@import "main/components/form"; @import "main/components/form";
@import "main/components/header"; @import "main/components/header";
@import "main/components/meta";
@import "main/components/nav"; @import "main/components/nav";
@import "main/components/pagination";
@import "main/components/post";
@import "main/components/search"; @import "main/components/search";
@import "main/components/select"; @import "main/components/select";
@import "main/components/sidebar"; @import "main/components/sidebar";
@import "main/components/source"; @import "main/components/source";
@import "main/components/status";
@import "main/components/tabs"; @import "main/components/tabs";
@import "main/components/tag"; @import "main/components/tag";
@import "main/components/tooltip"; @import "main/components/tooltip";

View File

@ -0,0 +1,86 @@
////
/// Copyright (c) 2016-2023 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;
transition:
color 125ms,
transform 125ms;
// Author image
img {
display: block;
border-radius: 100%;
}
// 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%);
transition: filter 125ms;
}
// Author on focus/hover
&:is(:focus, :hover) {
z-index: 1;
transform: scale(1.1);
// Author image
img {
filter: grayscale(0%);
}
}
}
}

View File

@ -0,0 +1,67 @@
////
/// Copyright (c) 2016-2023 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
// ----------------------------------------------------------------------------
// Metadata
.md-meta {
font-size: px2rem(14px);
line-height: 1.3;
color: var(--md-default-fg-color--light);
// Metadata list
&__list {
display: inline-flex;
flex-wrap: wrap;
padding: 0;
margin: 0;
list-style: none;
}
// Metadata item separator
&__item:not(:last-child)::after {
margin-inline: px2rem(4px);
content: "·";
}
// Metadata link
&__link {
color: var(--md-typeset-a-color);
// Metadata link on focus/hover
&:is(:focus, :hover) {
color: var(--md-accent-fg-color);
}
}
}
// Draft
.md-draft {
display: inline-block;
padding-inline: px2em(8px, 14px);
font-weight: 700;
color: hsla(255, 100%, 100%);
background-color: $clr-red-a400;
border-radius: px2em(2px);
}

View File

@ -93,12 +93,8 @@
// Navigation link // Navigation link
&__link { &__link {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between;
margin-top: 0.625em; margin-top: 0.625em;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: color 125ms; transition: color 125ms;
scroll-snap-align: start; scroll-snap-align: start;
@ -112,14 +108,22 @@
color: var(--md-typeset-a-color); color: var(--md-typeset-a-color);
} }
// Stretch section index link to full width // Navigation link icon
.md-nav__item &--index [href] { svg {
width: 100%; flex-shrink: 0;
height: 1.3em;
fill: currentcolor;
// Adjust spacing of next child
+ * {
margin-inline-start: px2rem(8px);
}
} }
// Navigation link on focus/hover // Navigation link on focus/hover
&:is(:focus, :hover) { &:not(.md-nav__container):is(:focus, :hover) {
color: var(--md-accent-fg-color); color: var(--md-accent-fg-color);
cursor: pointer;
} }
// Show outline for keyboard devices // Show outline for keyboard devices
@ -146,11 +150,15 @@
display: none; display: none;
} }
} }
}
// Navigation link children (for section indexes) // Navigation container (for section index pages)
> * { &__container > .md-nav__link {
display: flex; margin-top: 0;
cursor: pointer;
// Stretch first child
&:first-child {
flex-grow: 1;
} }
} }
@ -283,6 +291,16 @@
padding: px2rem(12px) px2rem(16px); padding: px2rem(12px) px2rem(16px);
margin-top: 0; margin-top: 0;
// Navigation link icon
svg {
margin-top: 0.1em;
}
// Adjust spacing on nested link
> .md-nav__link {
padding: 0;
}
// Navigation icon // Navigation icon
.md-nav__icon { .md-nav__icon {
width: px2rem(24px); width: px2rem(24px);
@ -515,16 +533,15 @@
// Show navigation link as title // Show navigation link as title
> .md-nav__link { > .md-nav__link {
font-weight: 700; font-weight: 700;
pointer-events: none;
// Make labels discernable from links // Make labels discernable from links
&[for] { &[for] {
color: var(--md-default-fg-color--light); color: var(--md-default-fg-color--light);
} }
// Make navigation link clickable // Omit clicks if not a section index page
&--index [href] { &:not(.md-nav__container) {
pointer-events: initial; pointer-events: none;
} }
// Hide naviation icon // Hide naviation icon
@ -613,8 +630,8 @@
background: var(--md-default-bg-color); background: var(--md-default-bg-color);
box-shadow: 0 0 px2rem(8px) px2rem(8px) var(--md-default-bg-color); box-shadow: 0 0 px2rem(8px) px2rem(8px) var(--md-default-bg-color);
// Non-index section should not be clickable // Omit clicks if not a section index page
&:not(.md-nav__link--index) { &:not(.md-nav__container) {
pointer-events: none; pointer-events: none;
} }

View File

@ -0,0 +1,85 @@
////
/// Copyright (c) 2016-2023 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
// ----------------------------------------------------------------------------
// Pagination
.md-pagination {
display: flex;
gap: px2rem(8px);
align-items: center;
justify-content: center;
font-size: px2rem(16px);
font-weight: 700;
// Pagination item
> * {
display: flex;
align-items: center;
justify-content: center;
min-width: px2rem(36px);
height: px2rem(36px);
text-align: center;
border-radius: px2rem(4px);
}
// Active pagination item
&__current {
color: var(--md-default-fg-color--light);
background-color: var(--md-default-fg-color--lightest);
}
// Pagination link
&__link {
transition:
color 125ms,
background-color 125ms;
// Pagination link on focus/hover
&:is(:focus, :hover) {
color: var(--md-accent-fg-color);
background-color: var(--md-accent-fg-color--transparent);
// Pagination icon
svg {
color: var(--md-accent-fg-color);
}
}
// Show outline for keyboard devices
&.focus-visible {
outline-color: var(--md-accent-fg-color);
outline-offset: px2rem(4px);
}
// Pagination icon
svg {
display: block;
width: px2rem(24px);
max-height: 100%;
color: var(--md-default-fg-color--lighter);
fill: currentcolor;
}
}
}

View File

@ -0,0 +1,161 @@
////
/// Copyright (c) 2016-2023 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
// ----------------------------------------------------------------------------
// Post
.md-post {
// Post backlink
&__back {
padding-bottom: px2rem(24px);
margin-bottom: px2rem(24px);
border-bottom: px2rem(1px) solid var(--md-default-fg-color--lightest);
// [tablet -]: Hide post backlink
@include break-to-device(tablet) {
display: none;
}
// Adjust for right-to-left languages
[dir="rtl"] & {
// Flip icon vertically
svg {
transform: scaleX(-1);
}
}
}
// Post authors
&__authors {
display: flex;
flex-direction: column;
gap: px2rem(12px);
margin: 0 px2rem(12px);
}
// Post metadata
.md-post__meta {
// Navigation link
a {
transition: color 125ms;
// Navigation link on focus/hover
&:is(:focus, :hover) {
color: var(--md-accent-fg-color);
}
}
}
// Post excerpt
&--excerpt {
margin-bottom: px2rem(64px);
// Post excerpt header
.md-post__header {
display: flex;
gap: px2rem(12px);
align-items: center;
min-height: px2rem(32px);
}
// Post excerpt authors
.md-post__authors {
display: inline-flex;
flex-direction: row;
gap: px2rem(4px);
align-items: center;
min-height: px2rem(48px);
margin: 0;
}
// Post excerpt metadata
.md-post__meta .md-meta__list {
margin-inline-end: px2rem(8px);
}
// Post excerpt content
.md-post__content > :first-child {
--md-scroll-margin: #{px2rem(120px)};
margin-top: 0;
}
}
// Adjust spacing for navigation
> .md-nav:first-child > .md-nav__list,
> .md-nav--secondary {
margin: 1em 0;
}
}
// ----------------------------------------------------------------------------
// Post author profile
.md-profile {
display: flex;
gap: px2rem(12px);
align-items: center;
width: 100%;
font-size: px2rem(14px);
line-height: 1.4;
// Post author description
&__description {
flex-grow: 1;
}
}
// ----------------------------------------------------------------------------
// Content area for post
.md-content--post {
display: flex;
// [tablet -]: Switch to inverted column layout
@include break-to-device(tablet) {
flex-flow: column-reverse;
}
// Content wrapper
> .md-content__inner {
min-width: 0;
// [screen +]: Adjust spacing between content area and sidebars
@include break-from-device(screen) {
margin-inline-start: px2rem(24px);
}
}
}
// Sidebar for post
.md-sidebar.md-sidebar--post {
// [tablet -]: Adjust spacing
@include break-to-device(tablet) {
padding: 0;
}
}

View File

@ -0,0 +1,74 @@
////
/// Copyright (c) 2016-2023 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
// ----------------------------------------------------------------------------
// Status variables
:root {
--md-status: svg-load("material/information-outline.svg");
--md-status--new: svg-load("material/alert-decagram.svg");
--md-status--deprecated: svg-load("material/trash-can.svg");
--md-status--encrypted: svg-load("material/shield-lock.svg");
}
// ----------------------------------------------------------------------------
// Status
.md-status {
margin-left: px2rem(4px);
// Status icon
&::after {
display: inline-block;
width: px2em(18px);
height: px2em(18px);
vertical-align: text-bottom;
content: "";
background-color: var(--md-default-fg-color--light);
mask-image: var(--md-status);
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
}
// Status icon on hover
&:hover::after {
background-color: currentcolor;
}
// Status: new
&--new::after {
mask-image: var(--md-status--new);
}
// Status: deprecated
&--deprecated::after {
mask-image: var(--md-status--deprecated);
}
// Status: encrypted
&--encrypted::after {
mask-image: var(--md-status--encrypted);
}
}

View File

@ -53,6 +53,7 @@
// Navigation tabs list // Navigation tabs list
&__list { &__list {
display: flex;
padding: 0; padding: 0;
margin: 0; margin: 0;
margin-inline-start: px2rem(4px); margin-inline-start: px2rem(4px);
@ -74,7 +75,6 @@
// Navigation tabs item // Navigation tabs item
&__item { &__item {
display: inline-block;
height: px2rem(48px); height: px2rem(48px);
padding-inline: px2rem(12px); padding-inline: px2rem(12px);
} }
@ -82,7 +82,7 @@
// Navigation tabs link - could be defined as block elements and aligned via // Navigation tabs link - could be defined as block elements and aligned via
// line height, but this would imply more repaints when scrolling // line height, but this would imply more repaints when scrolling
&__link { &__link {
display: block; display: flex;
margin-top: px2rem(16px); margin-top: px2rem(16px);
font-size: px2rem(14px); font-size: px2rem(14px);
outline-color: var(--md-accent-fg-color); outline-color: var(--md-accent-fg-color);
@ -101,6 +101,13 @@
opacity: 1; opacity: 1;
} }
// Navigation tabs link icon
svg {
height: 1.3em;
margin-inline-end: px2rem(8px);
fill: currentcolor;
}
// Delay transitions by a small amount // Delay transitions by a small amount
@for $i from 2 through 16 { @for $i from 2 through 16 {
.md-tabs__item:nth-child(#{$i}) & { .md-tabs__item:nth-child(#{$i}) & {

View File

@ -27,17 +27,14 @@
// Continuous pulse animation // Continuous pulse animation
@keyframes pulse { @keyframes pulse {
0% { 0% {
box-shadow: 0 0 0 0 var(--md-default-fg-color--lightest);
transform: scale(0.95); transform: scale(0.95);
} }
75% { 75% {
box-shadow: 0 0 0 px2em(10px) transparent;
transform: scale(1); transform: scale(1);
} }
100% { 100% {
box-shadow: 0 0 0 0 transparent;
transform: scale(0.95); transform: scale(0.95);
} }
} }
@ -48,6 +45,8 @@
// Tooltip variables // Tooltip variables
:root { :root {
--md-annotation-bg-icon: svg-load("material/circle.svg");
--md-annotation-icon: svg-load("material/plus-circle.svg");
--md-tooltip-width: #{px2rem(400px)}; --md-tooltip-width: #{px2rem(400px)};
} }
@ -124,6 +123,7 @@
.md-annotation { .md-annotation {
font-weight: 400; font-weight: 400;
white-space: normal; white-space: normal;
vertical-align: text-bottom;
outline: none; outline: none;
// Adjust for right-to-left languages // Adjust for right-to-left languages
@ -131,124 +131,155 @@
direction: rtl; direction: rtl;
} }
// Annotation is not hidden (e.g. when copying)
&:not([hidden]) {
display: inline-block;
// Hack: ensure that the line height doesn't exceed the line height of the
// hosting line, because it will lead to dancing pixels.
line-height: 1.325;
}
// Annotation index
&__index {
position: relative;
z-index: 0;
margin: 0 1ch;
font-family: var(--md-code-font-family);
font-size: px2em(13.6px, 16px);
cursor: pointer;
user-select: none;
outline: none;
// Hack: increase specificity to override default for anchors
.md-annotation & {
color: hsla(0, 0%, 100%, 1);
transition: z-index 250ms;
// Text link on focus/hover
&:is(:focus, :hover) {
color: hsla(0, 0%, 100%, 1);
}
}
// Annotation marker the marker must be positioned absolutely behind
// the index, because it shouldn't impact the rendering of a code block.
// Otherwise, small rounding differences in browsers can sometimes mess up
// alignment of text following an annotation.
&::after {
position: absolute;
top: 0;
left: px2em(-2px);
z-index: -1;
// Hack: the first property is used as a fallback for older browsers
// which don't support the min/max/clamp math functions.
width: calc(100% + 1.2ch);
width: max(2.2ch, 100% + 1.2ch);
height: 2.2ch;
padding: 0 0.4ch;
margin: 0 -0.4ch;
content: "";
background-color: var(--md-default-fg-color--lighter);
border-radius: 2ch;
transition:
color 250ms,
background-color 250ms;
// [reduced motion]: Disable animation
@media not all and (prefers-reduced-motion) {
// Annotation marker is visible
[data-md-visible] > & {
animation: pulse 2000ms infinite;
}
}
// Annotation marker for active tooltip
.md-tooltip--active + & {
transition:
color 250ms,
background-color 250ms;
animation: none;
}
}
// Annotation index in code block // Annotation index in code block
code & { code & {
font-family: var(--md-code-font-family); font-family: var(--md-code-font-family);
font-size: inherit; font-size: inherit;
} }
// Annotation index for active tooltip or on hover // Annotation is not hidden (e.g. when copying)
:is(.md-tooltip--active + &, :hover > &) { &:not([hidden]) {
color: var(--md-accent-bg-color); display: inline-block;
// Hack: ensure that the line height doesn't exceed the line height of the
// hosting line, because it will lead to dancing pixels.
line-height: 1.25;
}
// Annotation marker // Annotation index
&__index {
position: relative;
z-index: 0;
display: inline-block;
margin-inline: 0.4ch;
vertical-align: text-top;
cursor: pointer;
user-select: none;
outline: none;
// Hack: increase specificity to override default for anchors in typesetted
// content, because transitions are defined on anchor elements
.md-annotation & {
transition: z-index 250ms;
}
// [screen]: Render annotation markers as icons
@media screen {
width: 2.2ch;
// Annotation is visible
[data-md-visible] > & {
animation: pulse 2000ms infinite;
}
// Annotation marker background
&::before {
position: absolute;
top: -0.1ch;
z-index: -1;
width: 2.2ch;
height: 2.2ch;
content: "";
background: var(--md-default-bg-color);
mask-image: var(--md-annotation-bg-icon);
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
}
// Annotation marker the marker must be positioned absolutely behind
// the index, because it shouldn't impact the rendering of a code block.
// Otherwise, small rounding differences in browsers can sometimes mess up
// alignment of text following an annotation.
&::after { &::after {
position: absolute;
top: -0.1ch;
z-index: -1;
width: 2.2ch;
height: 2.2ch;
content: "";
background-color: var(--md-default-fg-color--lighter);
transition:
background-color 250ms,
transform 250ms;
// Hack: promote to own layer to reduce jitter
transform: scale(1.0001);
mask-image: var(--md-annotation-icon);
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
// Annotation marker for active tooltip
.md-tooltip--active + & {
transform: rotate(45deg);
}
// Annotation marker for active tooltip or on hover
:is(.md-tooltip--active + &, :hover > &) {
background-color: var(--md-accent-fg-color); background-color: var(--md-accent-fg-color);
} }
} }
}
// Annotation index for active tooltip // Annotation index for active tooltip
.md-tooltip--active + & { .md-tooltip--active + & {
z-index: 2; z-index: 2;
transition: none; transition-duration: 0ms;
animation: none; animation-play-state: paused;
} }
// Annotation marker // Annotation marker
[data-md-annotation-id] { [data-md-annotation-id] {
display: inline-block; display: inline-block;
line-height: 90%;
// [print]: Render annotation markers as numbers
@media print {
padding: 0 0.6ch;
font-weight: 700;
color: var(--md-default-bg-color);
white-space: nowrap;
background: var(--md-default-fg-color--lighter);
border-radius: 2ch;
// Annotation marker content // Annotation marker content
&::before { &::after {
display: inline-block;
padding-bottom: 0.1em;
vertical-align: 0.065em;
content: attr(data-md-annotation-id); content: attr(data-md-annotation-id);
transition: transform 400ms cubic-bezier(0.1, 0.7, 0.1, 1); }
transform: scale(1.15); }
}
}
}
// [not print]: if we're not in print mode, show a `+` sign instead of // ----------------------------------------------------------------------------
// the original numbers, as context is already given by the position.
@media not print {
content: "+";
// Annotation marker content on focus // Scoped in typesetted content to match specificity of regular content
:focus-within > & { .md-typeset {
transform: scale(1.25) rotate(45deg);
} // Annotation list
} .md-annotation-list {
list-style: none;
counter-reset: xxx;
// Annotation list item
li {
position: relative;
// Annotation list marker
&::before {
position: absolute;
top: px2em(4px);
inset-inline-start: px2em(-34px);
min-width: 2ch;
height: 2ch;
padding: 0 0.6ch;
font-size: px2em(14.2px);
font-weight: 700;
line-height: 1.25;
color: var(--md-default-bg-color);
text-align: center;
content: counter(xxx);
counter-increment: xxx;
background: var(--md-default-fg-color--lighter);
border-radius: 2ch;
} }
} }
} }

View File

@ -60,6 +60,22 @@
<link rel="next" href="{{ page.next_page.url | url }}" /> <link rel="next" href="{{ page.next_page.url | url }}" />
{% endif %} {% endif %}
<!-- RSS feed -->
{% 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 %}
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" href="{{ config.theme.favicon | url }}" /> <link rel="icon" href="{{ config.theme.favicon | url }}" />

41
src/blog-archive.html Normal file
View File

@ -0,0 +1,41 @@
<!--
Copyright (c) 2016-2023 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.
-->
{% extends "main.html" %}
<!-- Page content -->
{% block container %}
<div class="md-content" data-md-component="content">
<div class="md-content__inner">
<!-- Header -->
<header class="md-typeset">
{{ page.content }}
</header>
<!-- Posts -->
{% for post in posts %}
{% include "partials/post.html" %}
{% endfor %}
</div>
</div>
{% endblock %}

41
src/blog-category.html Normal file
View File

@ -0,0 +1,41 @@
<!--
Copyright (c) 2016-2023 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.
-->
{% extends "main.html" %}
<!-- Page content -->
{% block container %}
<div class="md-content" data-md-component="content">
<div class="md-content__inner">
<!-- Header -->
<header class="md-typeset">
{{ page.content }}
</header>
<!-- Posts -->
{% for post in posts %}
{% include "partials/post.html" %}
{% endfor %}
</div>
</div>
{% endblock %}

142
src/blog-post.html Normal file
View File

@ -0,0 +1,142 @@
<!--
Copyright (c) 2016-2023 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.
-->
{% extends "main.html" %}
{% import "partials/nav-item.html" as item with context %}
<!-- Page content -->
{% block container %}
<div class="md-content md-content--post" data-md-component="content">
<!-- Sidebar -->
<div
class="md-sidebar md-sidebar--post"
data-md-component="sidebar"
data-md-type="navigation"
>
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner md-post">
<nav class="md-nav">
<!-- Back to overview link -->
<div class="md-post__back">
<div class="md-nav__title md-nav__container">
<a href="{{ page.parent.url | url }}" class=" md-nav__link">
{% include ".icons/material/arrow-left.svg" %}
<span class="md-ellipsis">
{{ lang.t("blog.index") }}
</span>
</a>
</div>
</div>
<!-- Page authors -->
{% if page.authors %}
<div class="md-post__authors md-typeset">
{% for author in page.authors %}
<div class="md-profile md-post__profile">
<span class="md-author md-author--long">
<img src="{{ author.avatar }}" alt="{{ author.name }}" />
</span>
<span class="md-profile__description">
<strong>{{ author.name }}</strong><br />
{{ author.description }}
</span>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Page metadata -->
<ul class="md-post__meta md-nav__list">
<li class="md-nav__item md-nav__title">
<div class="md-nav__link">
<span class="md-ellipsis">
{{ lang.t("blog.meta") }}
</span>
</div>
</li>
<!-- Page date -->
<li class="md-nav__item">
<div class="md-nav__link">
{% include ".icons/material/calendar.svg" %}
<time datetime="{{ page.meta.date }}" class="md-ellipsis">
{{- page.meta.date_format -}}
</time>
</div>
</li>
<!-- Page categories -->
{% if page.categories %}
<li class="md-nav__item">
<div class="md-nav__link">
{% include ".icons/material/bookshelf.svg" %}
<span class="md-ellipsis">
{{ lang.t("blog.categories.in") }}
{% for category in page.categories %}
<a href="{{ category.url | url }}">
{{- category.title -}}
</a>
{%- if loop.revindex > 1 %}, {% endif -%}
{% endfor -%}
</span>
</div>
</li>
{% endif %}
<!-- Page readtime -->
{% if page.meta.readtime %}
{% set time = page.meta.readtime %}
<li class="md-nav__item">
<div class="md-nav__link">
{% include ".icons/material/clock-outline.svg" %}
<span class="md-ellipsis">
{% if time == 1 %}
{{ lang.t("readtime.one") }}
{% else %}
{{ lang.t("readtime.other") | replace("#", time) }}
{% endif %}
</span>
</div>
</li>
{% endif %}
</ul>
</nav>
<!-- Table of contents, if integrated -->
{% if "toc.integrate" in features %}
{% include "partials/toc.html" %}
{% endif %}
</div>
</div>
</div>
<!-- Page content -->
<article class="md-content__inner md-typeset">
{% block content %}
{% include "partials/content.html" %}
{% endblock %}
</article>
</div>
{% endblock %}

46
src/blog.html Normal file
View File

@ -0,0 +1,46 @@
<!--
Copyright (c) 2016-2023 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.
-->
{% extends "main.html" %}
<!-- Page content -->
{% block container %}
<div class="md-content" data-md-component="content">
<div class="md-content__inner">
<!-- Header -->
<header class="md-typeset">
{{ page.content }}
</header>
<!-- Posts -->
{% for post in posts %}
{% include "partials/post.html" %}
{% endfor %}
<!-- Pagination -->
{% block pagination %}
{% include "partials/pagination.html" %}
{% endblock %}
</div>
</div>
{% endblock %}

View File

@ -25,4 +25,4 @@
{% import "partials/languages/en.html" as fallback %} {% import "partials/languages/en.html" as fallback %}
<!-- Re-export translations --> <!-- Re-export translations -->
{% macro t(key) %}{{ lang.t(key) or fallback.t(key) }}{% endmacro %} {% macro t(key) %}{{ lang.t(key) or fallback.t(key) or key }}{% endmacro %}

View File

@ -46,9 +46,8 @@
"header": "頁首", "header": "頁首",
"meta.comments": "評論", "meta.comments": "評論",
"meta.source": "來源", "meta.source": "來源",
"search.config.lang": "ja",
"search.config.pipeline": "stemmer", "search.config.pipeline": "stemmer",
"search.config.separator": "[\\s\\-,。]+", "search.config.separator": "[\\s\\u200b\\u3000\\-、。,.?!;]+",
"nav": "導航", "nav": "導航",
"readtime.one": "需要 1 分鐘閲讀", "readtime.one": "需要 1 分鐘閲讀",
"readtime.other": "需要 # 分鐘閲讀", "readtime.other": "需要 # 分鐘閲讀",

View File

@ -52,9 +52,8 @@
"rss.created": "RSS 訂閱", "rss.created": "RSS 訂閱",
"rss.updated": "RSS 訂閱內容已更新", "rss.updated": "RSS 訂閱內容已更新",
"search": "搜尋", "search": "搜尋",
"search.config.lang": "ja",
"search.config.pipeline": "stemmer", "search.config.pipeline": "stemmer",
"search.config.separator": "[\\s\\- 、。,.?;]+", "search.config.separator": "[\\s\\u200b\\u3000\\-、。,.?]+",
"search.placeholder": "搜尋", "search.placeholder": "搜尋",
"search.share": "分享", "search.share": "分享",
"search.reset": "清除", "search.reset": "清除",

View File

@ -52,9 +52,8 @@
"rss.created": "RSS 订阅", "rss.created": "RSS 订阅",
"rss.updated": "已更新内容的 RSS 订阅", "rss.updated": "已更新内容的 RSS 订阅",
"search": "查找", "search": "查找",
"search.config.lang": "ja",
"search.config.pipeline": "stemmer", "search.config.pipeline": "stemmer",
"search.config.separator": "[\\s\\-,。]+", "search.config.separator": "[\\s\\u200b\\u3000\\-、。,.?!;]+",
"search.placeholder": "搜索", "search.placeholder": "搜索",
"search.share": "分享", "search.share": "分享",
"search.reset": "清空当前内容", "search.reset": "清空当前内容",

View File

@ -20,27 +20,101 @@
IN THE SOFTWARE. IN THE SOFTWARE.
--> -->
<!-- Wrap everything with a macro to reduce file roundtrips (see #2213) --> <!-- Render navigation link status -->
{% macro render_status(nav_item, type) %}
{% set class = "md-status md-status--" ~ type %}
<!-- Render icon with title (or tooltip), if given -->
{% if config.extra.status and config.extra.status[type] %}
<span
class="{{ class }}"
title="{{ config.extra.status[type] }}"
>
</span>
<!-- Render icon only -->
{% else %}
<span class="{{ class }}"></span>
{% endif %}
{% endmacro %}
<!-- Render navigation link content -->
{% macro render_content(nav_item, ref = nav_item) %}
<!-- Navigation link icon -->
{% if nav_item.is_page and nav_item.meta.icon %}
{% include ".icons/" ~ nav_item.meta.icon ~ ".svg" %}
{% endif %}
<!-- Navigation link title -->
<span class="md-ellipsis">
{{ nav_item.title }}
</span>
<!-- Navigation link status -->
{% if nav_item.is_page and nav_item.meta.status %}
{{ render_status(nav_item, nav_item.meta.status) }}
{% endif %}
{% endmacro %}
<!-- Render navigation item (pruned) -->
{% macro render_pruned(nav_item, ref = nav_item) %}
{% set first = nav_item.children | first %}
<!-- Recurse, if the first item has further nested items -->
{% if first and first.children %}
{{ render_pruned(first, ref) }}
<!-- Navigation link -->
{% else %}
<a href="{{ first.url | url }}" class="md-nav__link">
{{ render_content(first, ref) }}
<!-- Only render toggle if there's at least one more page -->
{% if nav_item.children | length > 1 %}
<span class="md-nav__icon md-icon"></span>
{% endif %}
</a>
{% endif %}
{% endmacro %}
<!-- Render navigation item -->
{% macro render(nav_item, path, level) %} {% macro render(nav_item, path, level) %}
<!-- Determine class according to state --> <!-- Determine base classes -->
{% set class = "md-nav__item" %} {% set class = "md-nav__item" %}
{% if nav_item.active %} {% if nav_item.active %}
{% set class = class ~ " md-nav__item--active" %} {% set class = class ~ " md-nav__item--active" %}
{% endif %} {% endif %}
<!-- Main navigation item with nested items --> <!-- Navigation item with nested items -->
{% if nav_item.children %} {% if nav_item.children %}
<!-- Determine all nested items that are index pages -->
{% set indexes = [] %}
{% if "navigation.indexes" in features %}
{% for nav_item in nav_item.children %}
{% if nav_item.is_index and not index is defined %}
{% set _ = indexes.append(nav_item) %}
{% endif %}
{% endfor %}
{% endif %}
<!-- Determine whether to render item as a section --> <!-- Determine whether to render item as a section -->
{% if "navigation.sections" in features and level == 1 + ( {% if "navigation.sections" in features and level == 1 + (
"navigation.tabs" in features "navigation.tabs" in features
) %} ) %}
{% set class = class ~ " md-nav__item--section" %} {% set class = class ~ " md-nav__item--section" %}
<!-- Determine whether to prune inactive item -->
{% elif not nav_item.active and "navigation.prune" in features %}
{% set class = class ~ " md-nav__item--pruned" %}
{% set prune = true %}
{% endif %} {% endif %}
<!-- Render item with nested items --> <!-- Nested navigation item -->
<li class="{{ class }} md-nav__item--nested"> <li class="{{ class }} md-nav__item--nested">
{% if not prune %}
{% set expanded = "navigation.expand" in features %} {% set expanded = "navigation.expand" in features %}
{% set active = nav_item.active or expanded %} {% set active = nav_item.active or expanded %}
@ -58,17 +132,7 @@
{{ checked }} {{ checked }}
/> />
<!-- Determine all nested items that are index pages --> <!-- Toggle to expand nested items -->
{% set indexes = [] %}
{% if "navigation.indexes" in features %}
{% for nav_item in nav_item.children %}
{% if nav_item.is_index and not index is defined %}
{% set _ = indexes.append(nav_item) %}
{% endif %}
{% endfor %}
{% endif %}
<!-- Render toggle to expand nested items -->
{% if not indexes %} {% if not indexes %}
<label <label
class="md-nav__link" class="md-nav__link"
@ -76,27 +140,32 @@
id="{{ path }}_label" id="{{ path }}_label"
tabindex="0" tabindex="0"
> >
{{ nav_item.title }} {{ render_content(nav_item) }}
<span class="md-nav__icon md-icon"></span> <span class="md-nav__icon md-icon"></span>
</label> </label>
<!-- Render link to index page + toggle --> <!-- Toggle to expand nested items with link to index page -->
{% else %} {% else %}
{% set index = indexes | first %} {% set index = indexes | first %}
{% set class = "md-nav__link--active" if index == page %} {% set class = "md-nav__link--active" if index == page %}
<div class="md-nav__link md-nav__link--index {{ class }}"> <div class="md-nav__link md-nav__container">
<a href="{{ index.url | url }}">{{ nav_item.title }}</a> <a
href="{{ index.url | url }}"
class="md-nav__link {{ class }}"
>
{{ render_content(index, nav_item) }}
</a>
<!-- Only render toggle if there's at least one more page --> <!-- Only render toggle if there's at least one more page -->
{% if nav_item.children | length > 1 %} {% if nav_item.children | length > 1 %}
<label for="{{ path }}"> <label class="md-nav__link {{ class }}" for="{{ path }}">
<span class="md-nav__icon md-icon"></span> <span class="md-nav__icon md-icon"></span>
</label> </label>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
<!-- Render nested navigation --> <!-- Nested navigation -->
<nav <nav
class="md-nav" class="md-nav"
data-md-level="{{ level }}" data-md-level="{{ level }}"
@ -109,7 +178,7 @@
</label> </label>
<ul class="md-nav__list" data-md-scrollfix> <ul class="md-nav__list" data-md-scrollfix>
<!-- Render nested item list --> <!-- Nested navigation item -->
{% for nav_item in nav_item.children %} {% for nav_item in nav_item.children %}
{% if not indexes or nav_item != indexes | first %} {% if not indexes or nav_item != indexes | first %}
{{ render(nav_item, path ~ "_" ~ loop.index, level + 1) }} {{ render(nav_item, path ~ "_" ~ loop.index, level + 1) }}
@ -117,6 +186,11 @@
{% endfor %} {% endfor %}
</ul> </ul>
</nav> </nav>
<!-- Pruned navigation item -->
{% else %}
{{ render_pruned(nav_item) }}
{% endif %}
</li> </li>
<!-- Currently active page --> <!-- Currently active page -->
@ -124,7 +198,7 @@
<li class="{{ class }}"> <li class="{{ class }}">
{% set toc = page.toc %} {% set toc = page.toc %}
<!-- Active checkbox expands items contained within nested section --> <!-- State toggle -->
<input <input
class="md-nav__toggle md-toggle" class="md-nav__toggle md-toggle"
type="checkbox" type="checkbox"
@ -137,10 +211,10 @@
{% set toc = first.children %} {% set toc = first.children %}
{% endif %} {% endif %}
<!-- Render table of contents, if not empty --> <!-- Navigation link to table of contents -->
{% if toc %} {% if toc %}
<label class="md-nav__link md-nav__link--active" for="__toc"> <label class="md-nav__link md-nav__link--active" for="__toc">
{{ nav_item.title }} {{ render_content(nav_item) }}
<span class="md-nav__icon md-icon"></span> <span class="md-nav__icon md-icon"></span>
</label> </label>
{% endif %} {% endif %}
@ -148,24 +222,21 @@
href="{{ nav_item.url | url }}" href="{{ nav_item.url | url }}"
class="md-nav__link md-nav__link--active" class="md-nav__link md-nav__link--active"
> >
{{ nav_item.title }} {{ render_content(nav_item) }}
</a> </a>
<!-- Show table of contents --> <!-- Table of contents -->
{% if toc %} {% if toc %}
{% include "partials/toc.html" %} {% include "partials/toc.html" %}
{% endif %} {% endif %}
</li> </li>
<!-- Main navigation item --> <!-- Navigation item -->
{% else %} {% else %}
<li class="{{ class }}"> <li class="{{ class }}">
<a href="{{ nav_item.url | url }}" class="md-nav__link"> <a href="{{ nav_item.url | url }}" class="md-nav__link">
{{ nav_item.title }} {{ render_content(nav_item) }}
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
<!-- Render current and nested navigation items -->
{{ render(nav_item, path, level) }}

View File

@ -20,7 +20,9 @@
IN THE SOFTWARE. IN THE SOFTWARE.
--> -->
<!-- Determine class according to configuration --> {% import "partials/nav-item.html" as item with context %}
<!-- Determine base classes -->
{% set class = "md-nav md-nav--primary" %} {% set class = "md-nav md-nav--primary" %}
{% if "navigation.tabs" in features %} {% if "navigation.tabs" in features %}
{% set class = class ~ " md-nav--lifted" %} {% set class = class ~ " md-nav--lifted" %}
@ -29,7 +31,7 @@
{% set class = class ~ " md-nav--integrated" %} {% set class = class ~ " md-nav--integrated" %}
{% endif %} {% endif %}
<!-- Main navigation --> <!-- Navigation -->
<nav <nav
class="{{ class }}" class="{{ class }}"
aria-label="{{ lang.t('nav') }}" aria-label="{{ lang.t('nav') }}"
@ -57,12 +59,11 @@
</div> </div>
{% endif %} {% endif %}
<!-- Render item list --> <!-- Navigation list -->
<ul class="md-nav__list" data-md-scrollfix> <ul class="md-nav__list" data-md-scrollfix>
{% for nav_item in nav %} {% for nav_item in nav %}
{% set path = "__nav_" ~ loop.index %} {% set path = "__nav_" ~ loop.index %}
{% set level = 1 %} {{ item.render(nav_item, path, 1) }}
{% include "partials/nav-item.html" %}
{% endfor %} {% endfor %}
</ul> </ul>
</nav> </nav>

View File

@ -0,0 +1,42 @@
<!--
Copyright (c) 2016-2023 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.
-->
<!-- Pagination icons -->
{% import ".icons/material/chevron-double-left.svg" as icon_first %}
{% import ".icons/material/chevron-left.svg" as icon_previous %}
{% import ".icons/material/chevron-right.svg" as icon_next %}
{% import ".icons/material/chevron-double-right.svg" as icon_last %}
<!-- Pagination -->
<nav class="md-pagination">
{{
pagination({
"link_attr": { "class": "md-pagination__link" },
"curpage_attr": { "class": "md-pagination__current" },
"dotdot_attr": { "class": "md-pagination__dots" },
"symbol_first": icon_first,
"symbol_previous": icon_previous,
"symbol_next": icon_next,
"symbol_last": icon_last
})
}}
</nav>

99
src/partials/post.html Normal file
View File

@ -0,0 +1,99 @@
<!--
Copyright (c) 2016-2023 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.
-->
<!-- Post excerpt -->
<article class="md-post md-post--excerpt">
<header class="md-post__header">
<!-- Post authors -->
{% if post.authors %}
<nav class="md-post__authors md-typeset">
{% for author in post.authors %}
<span class="md-author">
<img src="{{ author.avatar }}" alt="{{ author.name }}" />
</span>
{% endfor %}
</nav>
{% endif %}
<!-- Post metadata -->
<div class="md-post__meta md-meta">
<ul class="md-meta__list">
<!-- Post date -->
<li class="md-meta__item">
<time datetime="{{ post.meta.date }}">
{{- post.meta.date_format -}}
</time>
{#- Collapse whitespace -#}
</li>
<!-- Post categories -->
{% if post.categories %}
<li class="md-meta__item">
{{ lang.t("blog.categories.in") }}
{% for category in post.categories %}
<a
href="{{ category.url | url }}"
class="md-meta__link"
>
{{- category.title -}}
</a>
{%- if loop.revindex > 1 %}, {% endif -%}
{% endfor -%}
</li>
{% endif %}
<!-- Post readtime -->
{% if post.meta.readtime %}
{% set time = post.meta.readtime %}
<li class="md-meta__item">
{% if time == 1 %}
{{ lang.t("readtime.one") }}
{% else %}
{{ lang.t("readtime.other") | replace("#", time) }}
{% endif %}
</li>
{% endif %}
</ul>
<!-- Draft marker -->
{% if post.meta.draft %}
<span class="md-draft">
{{ lang.t("blog.draft") }}
</span>
{% endif %}
</div>
</header>
<!-- Post content -->
<div class="md-post__content md-typeset">
{{ post.content }}
<!-- Continue reading link -->
<nav class="md-post__action">
<a href="{{ post.url | url }}">
{{ lang.t("blog.continue") }}
</a>
</nav>
</div>
</article>

View File

@ -20,37 +20,52 @@
IN THE SOFTWARE. IN THE SOFTWARE.
--> -->
<!-- Render navigation link content -->
{% macro render_content(nav_item, ref = nav_item) %}
<!-- Navigation link icon -->
{% if nav_item == ref or "navigation.indexes" in features %}
{% if nav_item.is_index and nav_item.meta.icon %}
{% include ".icons/" ~ nav_item.meta.icon ~ ".svg" %}
{% endif %}
{% endif %}
<!-- Navigation link title -->
{{ ref.title }}
{% endmacro %}
<!-- Render navigation item -->
{% macro render(nav_item, ref = nav_item) %}
<!-- Determine class according to state --> <!-- Determine class according to state -->
{% if not class %}
{% set class = "md-tabs__link" %} {% set class = "md-tabs__link" %}
{% if nav_item.active %} {% if ref.active %}
{% set class = class ~ " md-tabs__link--active" %} {% set class = class ~ " md-tabs__link--active" %}
{% endif %} {% endif %}
{% endif %}
<!-- Main navigation item with nested items --> <!-- Navigation item with nested items -->
{% if nav_item.children %} {% if nav_item.children %}
{% set title = title | d(nav_item.title) %} {% set first = nav_item.children | first %}
{% set nav_item = nav_item.children | first %}
<!-- Recurse, if the first item has further nested items --> <!-- Recurse, if the first item has further nested items -->
{% if nav_item.children %} {% if first.children %}
{% include "partials/tabs-item.html" %} {{ render(first, ref) }}
<!-- Render item --> <!-- Nested navigation item -->
{% else %} {% else %}
<li class="md-tabs__item"> <li class="md-tabs__item">
<a href="{{ nav_item.url | url }}" class="{{ class }}"> <a href="{{ first.url | url }}" class="{{ class }}">
{{ title }} {{ render_content(first, ref) }}
</a> </a>
</li> </li>
{% endif %} {% endif %}
<!-- Main navigation item --> <!-- Navigation item -->
{% else %} {% else %}
<li class="md-tabs__item"> <li class="md-tabs__item">
<a href="{{ nav_item.url | url }}" class="{{ class }}"> <a href="{{ nav_item.url | url }}" class="{{ class }}">
{{ nav_item.title }} {{ render_content(nav_item) }}
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% endmacro %}

View File

@ -20,8 +20,7 @@
IN THE SOFTWARE. IN THE SOFTWARE.
--> -->
<!-- Hack: unset variable, as we're using it recursively in tabs-item.html --> {% import "partials/tabs-item.html" as item with context %}
{% set class = "" %}
<!-- Navigation tabs --> <!-- Navigation tabs -->
<nav <nav
@ -32,7 +31,7 @@
<div class="md-grid"> <div class="md-grid">
<ul class="md-tabs__list"> <ul class="md-tabs__list">
{% for nav_item in nav %} {% for nav_item in nav %}
{% include "partials/tabs-item.html" %} {{ item.render(nav_item) }}
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>

View File

View File

@ -0,0 +1,82 @@
# Copyright (c) 2016-2023 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 functools import partial
from markdown.extensions.toc import slugify
from mkdocs.config.config_options import Choice, Deprecated, Optional, Type
from mkdocs.config.base import Config
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Blog plugin configuration scheme
class BlogConfig(Config):
enabled = Type(bool, default = True)
# Options for blog
blog_dir = Type(str, default = "blog")
blog_toc = Type(bool, default = False)
# Options for posts
post_date_format = Type(str, default = "long")
post_url_date_format = Type(str, default = "yyyy/MM/dd")
post_url_format = Type(str, default = "{date}/{slug}")
post_url_max_categories = Type(int, default = 1)
post_slugify = Type((type(slugify), partial), default = slugify)
post_slugify_separator = Type(str, default = "-")
post_excerpt = Choice(["optional", "required"], default = "optional")
post_excerpt_max_authors = Type(int, default = 1)
post_excerpt_max_categories = Type(int, default = 5)
post_excerpt_separator = Type(str, default = "<!-- more -->")
post_readtime = Type(bool, default = True)
post_readtime_words_per_minute = Type(int, default = 265)
# Options for archive
archive = Type(bool, default = True)
archive_name = Type(str, default = "blog.archive")
archive_date_format = Type(str, default = "yyyy")
archive_url_date_format = Type(str, default = "yyyy")
archive_url_format = Type(str, default = "archive/{date}")
archive_toc = Optional(Type(bool))
# Options for categories
categories = Type(bool, default = True)
categories_name = Type(str, default = "blog.categories")
categories_url_format = Type(str, default = "category/{slug}")
categories_slugify = Type((type(slugify), partial), default = slugify)
categories_slugify_separator = Type(str, default = "-")
categories_allowed = Type(list, default = [])
categories_toc = Optional(Type(bool))
# Options for pagination
pagination = Type(bool, default = True)
pagination_per_page = Type(int, default = 10)
pagination_url_format = Type(str, default = "page/{page}")
pagination_template = Type(str, default = "~2~")
# Options for authors
authors = Type(bool, default = True)
authors_file = Type(str, default = "{blog}/.authors.yml")
# Options for drafts
draft = Type(bool, default = False)
draft_on_serve = Type(bool, default = True)
draft_if_future_date = Type(bool, default = False)

887
src/plugins/blog/plugin.py Normal file
View File

@ -0,0 +1,887 @@
# Copyright (c) 2016-2023 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 logging
import os
import paginate
import posixpath
import re
import readtime
import sys
from babel.dates import format_date
from copy import copy
from datetime import date, datetime, time
from hashlib import sha1
from lxml.html import fragment_fromstring, tostring
from mkdocs import utils
from mkdocs.utils.meta import get_data
from mkdocs.commands.build import _populate_page
from mkdocs.contrib.search import SearchIndex
from mkdocs.plugins import BasePlugin
from mkdocs.structure.files import File, Files
from mkdocs.structure.nav import Link, Section
from mkdocs.structure.pages import Page
from tempfile import gettempdir
from yaml import SafeLoader, load
from material.plugins.blog.config import BlogConfig
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Blog plugin
class BlogPlugin(BasePlugin[BlogConfig]):
supports_multiple_instances = True
# Determine whether we're running under dirty reload
def on_startup(self, *, command, dirty):
self.is_serve = (command == "serve")
self.is_dirtyreload = False
self.is_dirty = dirty
# Initialize plugin
def on_config(self, config):
if not self.config.enabled:
return
# Resolve source directory for posts and generated files
self.post_dir = self._resolve("posts")
self.temp_dir = gettempdir()
# Initialize posts
self.post_map = dict()
self.post_meta_map = dict()
self.post_pages = []
self.post_pager_pages = []
# Initialize archive
if self.config.archive:
self.archive_map = dict()
self.archive_post_map = dict()
# Initialize categories
if self.config.categories:
self.category_map = dict()
self.category_name_map = dict()
self.category_post_map = dict()
# Initialize authors
if self.config.authors:
self.authors_map = dict()
# Resolve authors file
path = os.path.normpath(os.path.join(
config.docs_dir,
self.config.authors_file.format(
blog = self.config.blog_dir
)
))
# Load authors map, if it exists
if os.path.isfile(path):
with open(path, encoding = "utf-8") as f:
self.authors_map = load(f, SafeLoader) or {}
# Ensure that format strings have no trailing slashes
for option in [
"post_url_format",
"archive_url_format",
"categories_url_format",
"pagination_url_format"
]:
if self.config[option].endswith("/"):
log.error(f"Option '{option}' must not contain trailing slash.")
sys.exit(1)
# Inherit global table of contents setting
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
# If pagination should not be used, set to large value
if not self.config.pagination:
self.config.pagination_per_page = 1e7
# By default, drafts are rendered when the documentation is served,
# but not when it is built. This should nicely align with the expected
# user experience when authoring documentation.
if self.is_serve and self.config.draft_on_serve:
self.config.draft = True
# Adjust paths to assets in the posts directory and preprocess posts
def on_files(self, files, *, config):
if not self.config.enabled:
return
# Adjust destination paths for assets
path = self._resolve("assets")
for file in files.media_files():
if self.post_dir not in file.src_uri:
continue
# Compute destination URL
file.url = file.url.replace(self.post_dir, path)
# Compute destination file system path
file.dest_uri = file.dest_uri.replace(self.post_dir, path)
file.abs_dest_path = os.path.join(config.site_dir, file.dest_path)
# Hack: as post URLs are dynamically computed and can be configured by
# the author, we need to compute them before we process the contents of
# any other page or post. If we wouldn't do that, URLs would be invalid
# and we would need to patch them afterwards. The only way to do this
# correctly is to first extract the metadata of all posts. Additionally,
# while we're at it, generate all archive and category pages as we have
# the post metadata on our hands. This ensures that we can safely link
# from anywhere to all pages that are generated as part of the blog.
for file in files.documentation_pages():
if self.post_dir not in file.src_uri:
continue
# Read and preprocess post
with open(file.abs_src_path, encoding = "utf-8") as f:
markdown, meta = get_data(f.read())
# Ensure post has a date set
if not meta.get("date"):
log.error(f"Blog post '{file.src_uri}' has no date set.")
sys.exit(1)
# Compute slug from metadata, content or file name
headline = utils.get_markdown_title(markdown)
slug = meta.get("title", headline or file.name)
# Front matter can be defind in YAML, guarded by two lines with
# `---` markers, or MultiMarkdown, separated by an empty line.
# If the author chooses to use MultiMarkdown syntax, date is
# returned as a string, which is different from YAML behavior,
# which returns a date. Thus, we must check for its type, and
# parse the date for normalization purposes.
if isinstance(meta["date"], str):
meta["date"] = date.fromisoformat(meta["date"])
# Normalize date to datetime for proper sorting
if not isinstance(meta["date"], datetime):
meta["date"] = datetime.combine(meta["date"], time())
# Compute category slugs
categories = []
for name in meta.get("categories", []):
categories.append(self.config.categories_slugify(
name, self.config.categories_slugify_separator
))
# Check if maximum number of categories is reached
max_categories = self.config.post_url_max_categories
if len(categories) == max_categories:
break
# Compute path from format string
date_format = self.config.post_url_date_format
path = self.config.post_url_format.format(
categories = "/".join(categories),
date = self._format_date(meta["date"], date_format, config),
file = file.name,
slug = meta.get("slug", self.config.post_slugify(
slug, self.config.post_slugify_separator
))
)
# Normalize path, as it may begin with a slash
path = posixpath.normpath("/".join([".", path]))
# Compute destination URL according to settings
file.url = self._resolve(path)
if not config.use_directory_urls:
file.url += ".html"
else:
file.url += "/"
# Compute destination file system path
file.dest_uri = re.sub(r"(?<=\/)$", "index.html", file.url)
file.abs_dest_path = os.path.join(
config.site_dir, file.dest_path
)
# Add post metadata
self.post_meta_map[file.src_uri] = meta
# Sort post metadata by date (descending)
self.post_meta_map = dict(sorted(
self.post_meta_map.items(),
key = lambda item: item[1]["date"], reverse = True
))
# Find and extract the section hosting the blog
path = self._resolve("index.md")
root = _host(config.nav, path)
# Ensure blog root exists
file = files.get_file_from_path(path)
if not file:
log.error(f"Blog root '{path}' does not exist.")
sys.exit(1)
# Ensure blog root is part of navigation
if not root:
log.error(f"Blog root '{path}' not in navigation.")
sys.exit(1)
# Generate and register files for archive
if self.config.archive:
name = self._translate(config, self.config.archive_name)
data = self._generate_files_for_archive(config, files)
if data:
root.append({ name: data })
# Generate and register files for categories
if self.config.categories:
name = self._translate(config, self.config.categories_name)
data = self._generate_files_for_categories(config, files)
if data:
root.append({ name: data })
# Hack: add posts temporarily, so MkDocs doesn't complain
name = sha1(path.encode("utf-8")).hexdigest()
root.append({
f"__posts_${name}": list(self.post_meta_map.keys())
})
# Cleanup navigation before proceeding
def on_nav(self, nav, *, config, files):
if not self.config.enabled:
return
# Find and resolve index for cleanup
path = self._resolve("index.md")
file = files.get_file_from_path(path)
# Determine blog root section
self.main = file.page
if self.main.parent:
root = self.main.parent.children
else:
root = nav.items
# Hack: remove temporarily added posts from the navigation
name = sha1(path.encode("utf-8")).hexdigest()
for item in root:
if not item.is_section or item.title != f"__posts_${name}":
continue
# Detach previous and next links of posts
if item.children:
head = item.children[+0]
tail = item.children[-1]
# Link page prior to posts to page after posts
if head.previous_page:
head.previous_page.next_page = tail.next_page
# Link page after posts to page prior to posts
if tail.next_page:
tail.next_page.previous_page = head.previous_page
# Contain previous and next links inside posts
head.previous_page = None
tail.next_page = None
# Set blog as parent page
for page in item.children:
page.parent = self.main
next = page.next_page
# Switch previous and next links
page.next_page = page.previous_page
page.previous_page = next
# Remove posts from navigation
root.remove(item)
break
# Prepare post for rendering
def on_page_markdown(self, markdown, *, page, config, files):
if not self.config.enabled:
return
# Only process posts
if self.post_dir not in page.file.src_uri:
return
# Skip processing of drafts
if self._is_draft(page.file.src_uri):
return
# Ensure template is set or use default
if "template" not in page.meta:
page.meta["template"] = "blog-post.html"
# Use previously normalized date
page.meta["date"] = self.post_meta_map[page.file.src_uri]["date"]
# Ensure navigation is hidden
page.meta["hide"] = page.meta.get("hide", [])
if "navigation" not in page.meta["hide"]:
page.meta["hide"].append("navigation")
# Format date for rendering
date_format = self.config.post_date_format
page.meta["date_format"] = self._format_date(
page.meta["date"], date_format, config
)
# Compute readtime if desired and not explicitly set
if self.config.post_readtime:
# There's a bug in the readtime library, which causes it to fail
# when the input string contains emojis (reported in #5555)
encoded = markdown.encode("unicode_escape")
if "readtime" not in page.meta:
rate = self.config.post_readtime_words_per_minute
read = readtime.of_markdown(encoded, rate)
page.meta["readtime"] = read.minutes
# Compute post categories
page.categories = []
if self.config.categories:
for name in page.meta.get("categories", []):
file = files.get_file_from_path(self.category_name_map[name])
page.categories.append(file.page)
# Compute post authors
page.authors = []
if self.config.authors:
for name in page.meta.get("authors", []):
if name not in self.authors_map:
log.error(
f"Blog post '{page.file.src_uri}' author '{name}' "
f"unknown, not listed in .authors.yml"
)
sys.exit(1)
# Add author to page
page.authors.append(self.authors_map[name])
# Fix stale link if previous post is a draft
prev = page.previous_page
while prev and self._is_draft(prev.file.src_uri):
page.previous_page = prev.previous_page
prev = prev.previous_page
# Fix stale link if next post is a draft
next = page.next_page
while next and self._is_draft(next.file.src_uri):
page.next_page = next.next_page
next = next.next_page
# Filter posts and generate excerpts for generated pages
def on_env(self, env, *, config, files):
if not self.config.enabled:
return
# Skip post excerpts on dirty reload to save time
if self.is_dirtyreload:
return
# Copy configuration and enable 'toc' extension
config = copy(config)
config.mdx_configs["toc"] = copy(config.mdx_configs.get("toc", {}))
# Ensure that post titles are links
config.mdx_configs["toc"]["anchorlink"] = True
config.mdx_configs["toc"]["permalink"] = False
# Filter posts that should not be published
for file in files.documentation_pages():
if self.post_dir in file.src_uri:
if self._is_draft(file.src_uri):
files.remove(file)
# Ensure template is set
if "template" not in self.main.meta:
self.main.meta["template"] = "blog.html"
# Populate archive
if self.config.archive:
for path in self.archive_map:
self.archive_post_map[path] = []
# Generate post excerpts for archive
base = files.get_file_from_path(path)
for file in self.archive_map[path]:
self.archive_post_map[path].append(
self._generate_excerpt(file, base, config, files)
)
# Ensure template is set
page = base.page
if "template" not in page.meta:
page.meta["template"] = "blog-archive.html"
# Populate categories
if self.config.categories:
for path in self.category_map:
self.category_post_map[path] = []
# Generate post excerpts for categories
base = files.get_file_from_path(path)
for file in self.category_map[path]:
self.category_post_map[path].append(
self._generate_excerpt(file, base, config, files)
)
# Ensure template is set
page = base.page
if "template" not in page.meta:
page.meta["template"] = "blog-category.html"
# Resolve path of initial index
curr = self._resolve("index.md")
base = self.main.file
# Initialize index
self.post_map[curr] = []
self.post_pager_pages.append(self.main)
# Generate indexes by paginating through posts
for path in self.post_meta_map.keys():
file = files.get_file_from_path(path)
if not self._is_draft(path):
self.post_pages.append(file.page)
else:
continue
# Generate new index when the current is full
per_page = self.config.pagination_per_page
if len(self.post_map[curr]) == per_page:
offset = 1 + len(self.post_map)
# Resolve path of new index
curr = self.config.pagination_url_format.format(page = offset)
curr = self._resolve(curr + ".md")
# Generate file
self._generate_file(curr, f"# {self.main.title}")
# Register file and page
base = self._register_file(curr, config, files)
page = self._register_page(base, config, files)
# Inherit page metadata, title and position
page.meta = self.main.meta
page.title = self.main.title
page.parent = self.main
page.previous_page = self.main.previous_page
page.next_page = self.main.next_page
# Initialize next index
self.post_map[curr] = []
self.post_pager_pages.append(page)
# Assign post excerpt to current index
self.post_map[curr].append(
self._generate_excerpt(file, base, config, files)
)
# Populate generated pages
def on_page_context(self, context, *, page, config, nav):
if not self.config.enabled:
return
# Provide post excerpts for index
path = page.file.src_uri
if path in self.post_map:
context["posts"] = self.post_map[path]
if self.config.blog_toc:
self._populate_toc(page, context["posts"])
# Create pagination
pagination = paginate.Page(
self.post_pages,
page = list(self.post_map.keys()).index(path) + 1,
items_per_page = self.config.pagination_per_page,
url_maker = lambda n: utils.get_relative_url(
self.post_pager_pages[n - 1].url,
page.url
)
)
# Create pagination pager
context["pagination"] = lambda args: pagination.pager(
format = self.config.pagination_template,
show_if_single_page = False,
**args
)
# Provide post excerpts for archive
if self.config.archive:
if path in self.archive_post_map:
context["posts"] = self.archive_post_map[path]
if self.config.archive_toc:
self._populate_toc(page, context["posts"])
# Provide post excerpts for categories
if self.config.categories:
if path in self.category_post_map:
context["posts"] = self.category_post_map[path]
if self.config.categories_toc:
self._populate_toc(page, context["posts"])
# Determine whether we're running under dirty reload
def on_serve(self, server, *, config, builder):
self.is_dirtyreload = self.is_dirty
# -------------------------------------------------------------------------
# Generate and register files for archive
def _generate_files_for_archive(self, config, files):
for path, meta in self.post_meta_map.items():
file = files.get_file_from_path(path)
if self._is_draft(path):
continue
# Compute name from format string
date_format = self.config.archive_date_format
name = self._format_date(meta["date"], date_format, config)
# Compute path from format string
date_format = self.config.archive_url_date_format
path = self.config.archive_url_format.format(
date = self._format_date(meta["date"], date_format, config)
)
# Create file for archive if it doesn't exist
path = self._resolve(path + ".md")
if path not in self.archive_map:
self.archive_map[path] = []
# Generate and register file for archive
self._generate_file(path, f"# {name}")
self._register_file(path, config, files)
# Assign current post to archive
self.archive_map[path].append(file)
# Return generated archive files
return list(self.archive_map.keys())
# Generate and register files for categories
def _generate_files_for_categories(self, config, files):
allowed = set(self.config.categories_allowed)
for path, meta in self.post_meta_map.items():
file = files.get_file_from_path(path)
if self._is_draft(path):
continue
# Ensure category is in (non-empty) allow list
categories = set(meta.get("categories", []))
if allowed:
for name in categories - allowed:
log.error(
f"Blog post '{file.src_uri}' uses a category "
f"which is not in allow list: {name}"
)
sys.exit(1)
# Traverse all categories of the post
for name in categories:
path = self.config.categories_url_format.format(
slug = self.config.categories_slugify(
name, self.config.categories_slugify_separator
)
)
# Create file for category if it doesn't exist
path = self._resolve(path + ".md")
if path not in self.category_map:
self.category_map[path] = []
# Generate and register file for category
self._generate_file(path, f"# {name}")
self._register_file(path, config, files)
# Link category path to name
self.category_name_map[name] = path
# Assign current post to category
self.category_map[path].append(file)
# Sort categories alphabetically (ascending)
self.category_map = dict(sorted(self.category_map.items()))
# Return generated category files
return list(self.category_map.keys())
# -------------------------------------------------------------------------
# Check if a post is a draft
def _is_draft(self, path):
meta = self.post_meta_map[path]
if not self.config.draft:
# Check if post date is in the future
future = False
if self.config.draft_if_future_date:
future = meta["date"] > datetime.now()
# Check if post is marked as draft
return meta.get("draft", future)
# Post is not a draft
return False
# Generate a post excerpt relative to base
def _generate_excerpt(self, file, base, config, files):
page = file.page
# Generate temporary file and page for post excerpt
temp = self._register_file(file.src_uri, config)
excerpt = Page(page.title, temp, config)
# Check for separator, if post excerpt is required
separator = self.config.post_excerpt_separator
if self.config.post_excerpt == "required":
if separator not in page.markdown:
log.error(f"Blog post '{temp.src_uri}' has no excerpt.")
sys.exit(1)
# Ensure separator at the end to strip footnotes and patch h1-h5
markdown = "\n\n".join([page.markdown, separator])
markdown = re.sub(r"(^#{1,5})", "#\\1", markdown, flags = re.MULTILINE)
# Extract content and metadata from original post
excerpt.file.url = base.url
excerpt.markdown = markdown
excerpt.meta = page.meta
# Render post and revert page URL
excerpt.render(config, files)
excerpt.file.url = page.url
# Find all anchor links
expr = re.compile(
r"<a[^>]+href=['\"]?#[^>]+>",
re.IGNORECASE | re.MULTILINE
)
# Replace callback
first = True
def replace(match):
value = match.group()
# Handle anchor link
el = fragment_fromstring(value.encode("utf-8"))
if el.tag == "a":
nonlocal first
# Fix up each anchor link of the excerpt with a link to the
# anchor of the actual post, except for the first one that
# one needs to go to the top of the post. A better way might
# be a Markdown extension, but for now this should be fine.
url = utils.get_relative_url(excerpt.file.url, base.url)
if first:
el.set("href", url)
else:
el.set("href", url + el.get("href"))
# From now on reference anchors
first = False
# Replace link opening tag (without closing tag)
return tostring(el, encoding = "unicode")[:-4]
# Extract excerpt from post and replace anchor links
excerpt.content = expr.sub(
replace,
excerpt.content.split(separator)[0]
)
# Determine maximum number of authors and categories
max_authors = self.config.post_excerpt_max_authors
max_categories = self.config.post_excerpt_max_categories
# Obtain computed metadata from original post
excerpt.authors = page.authors[:max_authors]
excerpt.categories = page.categories[:max_categories]
# Return post excerpt
return excerpt
# Generate a file with the given template and content
def _generate_file(self, path, content):
content = f"---\nsearch:\n exclude: true\n---\n\n{content}"
utils.write_file(
bytes(content, "utf-8"),
os.path.join(self.temp_dir, path)
)
# Register a file
def _register_file(self, path, config, files = Files([])):
file = files.get_file_from_path(path)
if not file:
urls = config.use_directory_urls
file = File(path, self.temp_dir, config.site_dir, urls)
files.append(file)
# Mark file as generated, so other plugins don't think it's part
# of the file system. This is more or less a new quasi-standard
# for plugins that generate files which was introduced by the
# git-revision-date-localized-plugin - see https://bit.ly/3ZUmdBx
file.generated_by = "material/blog"
# Return file
return file
# Register and populate a page
def _register_page(self, file, config, files):
page = Page(None, file, config)
_populate_page(page, config, files)
return page
# Populate table of contents of given page
def _populate_toc(self, page, posts):
toc = page.toc.items[0]
for post in posts:
toc.children.append(post.toc.items[0])
# Remove anchors below the second level
post.toc.items[0].children = []
# Translate the given placeholder value
def _translate(self, config, value):
env = config.theme.get_env()
# Load language template and return translation for placeholder
language = "partials/language.html"
template = env.get_template(language, None, { "config": config })
return template.module.t(value)
# Resolve path relative to blog root
def _resolve(self, *args):
path = posixpath.join(self.config.blog_dir, *args)
return posixpath.normpath(path)
# Format date according to locale
def _format_date(self, date, format, config):
return format_date(
date,
format = format,
locale = config.theme["language"]
)
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
# Search the given navigation section (from the configuration) recursively to
# find the section to host all generated pages (archive, categories, etc.)
def _host(nav, path):
# Search navigation dictionary
if isinstance(nav, dict):
for _, item in nav.items():
result = _host(item, path)
if result:
return result
# Search navigation list
elif isinstance(nav, list):
if path in nav:
return nav
# Search each list item
for item in nav:
if isinstance(item, dict) and path in item.values():
if path in item.values():
return nav
else:
result = _host(item, path)
if result:
return result
# Copied and adapted from MkDocs, because we need to return existing pages and
# support anchor names as subtitles, which is pretty fucking cool.
def _data_to_navigation(nav, config, files):
# Search navigation dictionary
if isinstance(nav, dict):
return [
_data_to_navigation((key, value), config, files)
if isinstance(value, str) else
Section(
title = key,
children = _data_to_navigation(value, config, files)
)
for key, value in nav.items()
]
# Search navigation list
elif isinstance(nav, list):
return [
_data_to_navigation(item, config, files)[0]
if isinstance(item, dict) and len(item) == 1 else
_data_to_navigation(item, config, files)
for item in nav
]
# Extract navigation title and path and split anchors
title, path = nav if isinstance(nav, tuple) else (None, nav)
path, _, anchor = path.partition("#")
# Try to retrieve existing file
file = files.get_file_from_path(path)
if not file:
return Link(title, path)
# Use resolved assets destination path
if not path.endswith(".md"):
return Link(title or os.path.basename(path), file.url)
# Generate temporary file as for post excerpts
else:
urls = config.use_directory_urls
link = File(path, config.docs_dir, config.site_dir, urls)
page = Page(title or file.page.title, link, config)
# Set destination file system path and URL from original file
link.dest_uri = file.dest_uri
link.abs_dest_path = file.abs_dest_path
link.url = file.url
# Retrieve name of anchor by misusing the search index
if anchor:
item = SearchIndex()._find_toc_by_id(file.page.toc, anchor)
# Set anchor name as subtitle
page.meta["subtitle"] = item.title
link.url += f"#{anchor}"
# Return navigation item
return page
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.blog")

View File

@ -0,0 +1,36 @@
# Copyright (c) 2016-2023 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
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Info plugin configuration scheme
class InfoConfig(Config):
enabled = Type(bool, default = True)
enabled_on_serve = Type(bool, default = False)
# Options for archive
archive = Type(bool, default = True)
archive_name = Type(str, default = "example")
archive_stop_on_violation = Type(bool, default = True)

View File

@ -28,32 +28,19 @@ import sys
from colorama import Fore, Style from colorama import Fore, Style
from io import BytesIO from io import BytesIO
from mkdocs import utils from mkdocs import utils
from mkdocs.commands.build import DuplicateFilter
from mkdocs.config import config_options as opt
from mkdocs.config.base import Config
from mkdocs.plugins import BasePlugin, event_priority from mkdocs.plugins import BasePlugin, event_priority
from mkdocs.structure.files import get_files from mkdocs.structure.files import get_files
from pkg_resources import get_distribution, working_set from pkg_resources import get_distribution, working_set
from zipfile import ZipFile, ZIP_DEFLATED from zipfile import ZipFile, ZIP_DEFLATED
from material.plugins.info.config import InfoConfig
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Class # Class
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Info plugin configuration scheme
class InfoPluginConfig(Config):
enabled = opt.Type(bool, default = True)
enabled_on_serve = opt.Type(bool, default = False)
# Options for archive
archive = opt.Type(bool, default = True)
archive_name = opt.Type(str, default = "example")
archive_stop_on_violation = opt.Type(bool, default = True)
# -----------------------------------------------------------------------------
# Info plugin # Info plugin
class InfoPlugin(BasePlugin[InfoPluginConfig]): class InfoPlugin(BasePlugin[InfoConfig]):
# Determine whether we're serving # Determine whether we're serving
def on_startup(self, *, command, dirty): def on_startup(self, *, command, dirty):
@ -235,5 +222,4 @@ def _size(value, factor = 1):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Set up logging # Set up logging
log = logging.getLogger("mkdocs") log = logging.getLogger("mkdocs.material.info")
log.addFilter(DuplicateFilter())

View File

@ -0,0 +1,30 @@
# Copyright (c) 2016-2023 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
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Offline plugin configuration scheme
class OfflineConfig(Config):
enabled = Type(bool, default = True)

View File

@ -21,22 +21,16 @@
import os import os
from mkdocs import utils from mkdocs import utils
from mkdocs.config import config_options as opt
from mkdocs.config.base import Config
from mkdocs.plugins import BasePlugin, event_priority from mkdocs.plugins import BasePlugin, event_priority
from material.plugins.offline.config import OfflineConfig
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Class # Class
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Offline plugin configuration scheme
class OfflinePluginConfig(Config):
enabled = opt.Type(bool, default = True)
# -----------------------------------------------------------------------------
# Offline plugin # Offline plugin
class OfflinePlugin(BasePlugin[OfflinePluginConfig]): class OfflinePlugin(BasePlugin[OfflineConfig]):
# Initialize plugin # Initialize plugin
def on_config(self, config): def on_config(self, config):

View File

@ -0,0 +1,51 @@
# Copyright (c) 2016-2023 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 (
Choice,
Deprecated,
Optional,
ListOfItems,
Type
)
from mkdocs.config.base import Config
from mkdocs.contrib.search import LangOption
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Search pipeline functions
pipeline = ("stemmer", "stopWordFilter", "trimmer")
# Search plugin configuration scheme
class SearchConfig(Config):
lang = Optional(LangOption())
separator = Optional(Type(str))
pipeline = ListOfItems(Choice(pipeline), default = [])
# Options for text segmentation (Chinese)
jieba_dict = Optional(Type(str))
jieba_dict_user = Optional(Type(str))
# Unsupported options, originally implemented in MkDocs
indexing = Deprecated(message = "Unsupported option")
prebuild_index = Deprecated(message = "Unsupported option")
min_search_length = Deprecated(message = "Unsupported option")

View File

@ -26,34 +26,22 @@ import regex as re
from html import escape from html import escape
from html.parser import HTMLParser from html.parser import HTMLParser
from mkdocs import utils from mkdocs import utils
from mkdocs.commands.build import DuplicateFilter from mkdocs.config.config_options import SubConfig
from mkdocs.config import config_options as opt
from mkdocs.config.base import Config
from mkdocs.contrib.search import LangOption
from mkdocs.plugins import BasePlugin from mkdocs.plugins import BasePlugin
from material.plugins.search.config import SearchConfig, SearchFieldConfig
try:
import jieba
except ImportError:
jieba = None
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Class # Class
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Search plugin configuration scheme
class SearchPluginConfig(Config):
lang = opt.Optional(LangOption())
separator = opt.Optional(opt.Type(str))
pipeline = opt.ListOfItems(
opt.Choice(("stemmer", "stopWordFilter", "trimmer")),
default = []
)
# Deprecated options
indexing = opt.Deprecated(message = "Unsupported option")
prebuild_index = opt.Deprecated(message = "Unsupported option")
min_search_length = opt.Deprecated(message = "Unsupported option")
# -----------------------------------------------------------------------------
# Search plugin # Search plugin
class SearchPlugin(BasePlugin[SearchPluginConfig]): class SearchPlugin(BasePlugin[SearchConfig]):
# Determine whether we're running under dirty reload # Determine whether we're running under dirty reload
def on_startup(self, *, command, dirty): def on_startup(self, *, command, dirty):
@ -85,6 +73,30 @@ class SearchPlugin(BasePlugin[SearchPluginConfig]):
# Initialize search index # Initialize search index
self.search_index = SearchIndex(**self.config) self.search_index = SearchIndex(**self.config)
# Set jieba dictionary, if given
if self.config.jieba_dict:
path = os.path.normpath(self.config.jieba_dict)
if os.path.exists(path):
jieba.set_dictionary(path)
log.debug(f"Loading jieba dictionary: {path}")
else:
log.warning(
f"Configuration error for 'search.jieba_dict': "
f"'{self.config.jieba_dict}' does not exist."
)
# Set jieba user dictionary, if given
if self.config.jieba_dict_user:
path = os.path.normpath(self.config.jieba_dict_user)
if os.path.exists(path):
jieba.load_userdict(path)
log.debug(f"Loading jieba user dictionary: {path}")
else:
log.warning(
f"Configuration error for 'search.jieba_dict_user': "
f"'{self.config.jieba_dict_user}' does not exist."
)
# Add page to search index # Add page to search index
def on_page_context(self, context, *, page, config, nav): def on_page_context(self, context, *, page, config, nav):
self.search_index.add_entry_from_context(page) self.search_index.add_entry_from_context(page)
@ -167,9 +179,10 @@ class SearchIndex:
title = "".join(section.title).strip() title = "".join(section.title).strip()
text = "".join(section.text).strip() text = "".join(section.text).strip()
# Reset text, if only titles should be indexed # Segment Chinese characters if jieba is available
if self.config["indexing"] == "titles": if jieba:
text = "" title = self._segment_chinese(title)
text = self._segment_chinese(text)
# Create entry for section # Create entry for section
entry = { entry = {
@ -252,6 +265,25 @@ class SearchIndex:
# No item found # No item found
return None return None
# Find and segment Chinese characters in string
def _segment_chinese(self, data):
expr = re.compile(r"(\p{IsHan}+)", re.UNICODE)
# Replace callback
def replace(match):
value = match.group(0)
# Replace occurrence in original string with segmented version and
# surround with zero-width whitespace for efficient indexing
return "".join([
"\u200b",
"\u200b".join(jieba.cut(value.encode("utf-8"))),
"\u200b",
])
# Return string with segmented occurrences
return expr.sub(replace, data).strip("\u200b")
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# HTML element # HTML element
@ -341,7 +373,8 @@ class Parser(HTMLParser):
self.keep = set([ self.keep = set([
"p", # Paragraphs "p", # Paragraphs
"code", "pre", # Code blocks "code", "pre", # Code blocks
"li", "ol", "ul" # Lists "li", "ol", "ul", # Lists
"sub", "sup" # Sub- and superscripts
]) ])
# Current context and section # Current context and section
@ -362,7 +395,7 @@ class Parser(HTMLParser):
else: else:
return return
# Handle headings # Handle heading
if tag in ([f"h{x}" for x in range(1, 7)]): if tag in ([f"h{x}" for x in range(1, 7)]):
depth = len(self.context) depth = len(self.context)
if "id" in attrs: if "id" in attrs:
@ -507,8 +540,7 @@ class Parser(HTMLParser):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Set up logging # Set up logging
log = logging.getLogger("mkdocs") log = logging.getLogger("mkdocs.material.search")
log.addFilter(DuplicateFilter())
# Tags that are self-closing # Tags that are self-closing
void = set([ void = set([

View File

@ -0,0 +1,33 @@
# Copyright (c) 2016-2023 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 logging
import sys
try:
import cairosvg as _
import PIL as _
except ImportError:
log = logging.getLogger("mkdocs.material.social")
log.error(
"Required dependencies of \"social\" plugin not found. "
"Install with: pip install pillow cairosvg"
)
sys.exit(1)

View File

@ -0,0 +1,48 @@
# Copyright (c) 2016-2023 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.base import Config
from mkdocs.config.config_options import Deprecated, Type
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Social plugin configuration scheme
class SocialConfig(Config):
enabled = Type(bool, default = True)
cache_dir = Type(str, default = ".cache/plugin/social")
# Options for social cards
cards = Type(bool, default = True)
cards_dir = Type(str, default = "assets/images/social")
cards_layout_options = Type(dict, default = {})
# Deprecated options
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'"
)

View File

@ -25,56 +25,26 @@ import os
import posixpath import posixpath
import re import re
import requests import requests
import sys
from cairosvg import svg2png
from collections import defaultdict from collections import defaultdict
from hashlib import md5 from hashlib import md5
from io import BytesIO from io import BytesIO
from mkdocs.commands.build import DuplicateFilter from mkdocs.commands.build import DuplicateFilter
from mkdocs.config import config_options as opt
from mkdocs.config.base import Config
from mkdocs.plugins import BasePlugin from mkdocs.plugins import BasePlugin
from PIL import Image, ImageDraw, ImageFont
from shutil import copyfile from shutil import copyfile
from tempfile import TemporaryFile from tempfile import TemporaryFile
from zipfile import ZipFile from zipfile import ZipFile
try: from material.plugins.social.config import SocialConfig
from cairosvg import svg2png
from PIL import Image, ImageDraw, ImageFont
dependencies = True
except ImportError:
dependencies = False
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Class # Class
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Social plugin configuration scheme
class SocialPluginConfig(Config):
enabled = opt.Type(bool, default = True)
cache_dir = opt.Type(str, default = ".cache/plugin/social")
# Options for social cards
cards = opt.Type(bool, default = True)
cards_dir = opt.Type(str, default = "assets/images/social")
cards_layout_options = opt.Type(dict, default = {})
# Deprecated options
cards_color = opt.Deprecated(
option_type = opt.Type(dict, default = {}),
message =
"Deprecated, use 'cards_layout_options.background_color' "
"and 'cards_layout_options.color' with 'default' layout"
)
cards_font = opt.Deprecated(
option_type = opt.Type(str),
message = "Deprecated, use 'cards_layout_options.font_family'"
)
# -----------------------------------------------------------------------------
# Social plugin # Social plugin
class SocialPlugin(BasePlugin[SocialPluginConfig]): class SocialPlugin(BasePlugin[SocialConfig]):
def __init__(self): def __init__(self):
self._executor = concurrent.futures.ThreadPoolExecutor(4) self._executor = concurrent.futures.ThreadPoolExecutor(4)
@ -104,14 +74,6 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]):
value = self.config.cards_font value = self.config.cards_font
self.config.cards_layout_options["font_family"] = value self.config.cards_layout_options["font_family"] = value
# Check if required dependencies are installed
if not dependencies:
log.error(
"Required dependencies of \"social\" plugin not found. "
"Install with: pip install pillow cairosvg"
)
sys.exit(1)
# Check if site URL is defined # Check if site URL is defined
if not config.site_url: if not config.site_url:
log.warning( log.warning(

View File

@ -0,0 +1,27 @@
# Copyright (c) 2016-2023 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.
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Casefold a string for comparison when sorting
def casefold(tag: str):
return tag.casefold()

View File

@ -0,0 +1,43 @@
# Copyright (c) 2016-2023 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 functools import partial
from markdown.extensions.toc import slugify
from mkdocs.config.config_options import Optional, Type
from mkdocs.config.base import Config
from material.plugins.tags import casefold
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Tags plugin configuration scheme
class TagsConfig(Config):
enabled = Type(bool, default = True)
# Options for tags
tags_file = Optional(Type(str))
tags_extra_files = Type(dict, default = dict())
tags_slugify = Type((type(slugify), partial), default = slugify)
tags_slugify_separator = Type(str, default = "-")
tags_compare = Optional(Type(type(casefold)))
tags_compare_reverse = Type(bool, default = False)
tags_allowed = Type(list, default = [])

View File

@ -24,26 +24,19 @@ import sys
from collections import defaultdict from collections import defaultdict
from markdown.extensions.toc import slugify from markdown.extensions.toc import slugify
from mkdocs import utils from mkdocs import utils
from mkdocs.commands.build import DuplicateFilter
from mkdocs.config.base import Config
from mkdocs.config import config_options as opt
from mkdocs.plugins import BasePlugin from mkdocs.plugins import BasePlugin
# deprecated, but kept for downward compatibility. Use 'material.plugins.tags'
# as an import source instead. This import is removed in the next major version.
from material.plugins.tags import casefold
from material.plugins.tags.config import TagsConfig
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Class # Class
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Tags plugin configuration scheme
class TagsPluginConfig(Config):
enabled = opt.Type(bool, default = True)
# Options for tags
tags_file = opt.Optional(opt.Type(str))
# -----------------------------------------------------------------------------
# Tags plugin # Tags plugin
class TagsPlugin(BasePlugin[TagsPluginConfig]): class TagsPlugin(BasePlugin[TagsConfig]):
supports_multiple_instances = True supports_multiple_instances = True
# Initialize plugin # Initialize plugin
@ -166,5 +159,4 @@ class TagsPlugin(BasePlugin[TagsPluginConfig]):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Set up logging # Set up logging
log = logging.getLogger("mkdocs") log = logging.getLogger("mkdocs.material.tags")
log.addFilter(DuplicateFilter())