1
0
mirror of https://github.com/squidfunk/mkdocs-material.git synced 2024-11-27 17:00:54 +01:00

Merge branch 'merge/piri-piri' into refactor/polyfills

This commit is contained in:
squidfunk 2023-07-06 13:38:30 +02:00
commit e6f779933a
No known key found for this signature in database
GPG Key ID: 5ED40BC4F9C436DF
99 changed files with 4625 additions and 666 deletions

View File

@ -23,5 +23,5 @@
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ 'assets/javascripts/custom.ba4126fd.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/custom.99dda5c2.min.js' | url }}"></script>
{% 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

View File

@ -27,6 +27,10 @@
{% if page.next_page %}
<link rel="next" href="{{ page.next_page.url | url }}">
{% endif %}
{% if "rss" in config.plugins %}
<link rel="alternate" type="application/rss+xml" title="{{ lang.t('rss.created') }}" href="{{ 'feed_rss_created.xml' | url }}">
<link rel="alternate" type="application/rss+xml" title="{{ lang.t('rss.updated') }}" href="{{ 'feed_rss_updated.xml' | url }}">
{% endif %}
<link rel="icon" href="{{ config.theme.favicon | url }}">
<meta name="generator" content="mkdocs-{{ mkdocs_version }}, mkdocs-material-9.1.18">
{% endblock %}
@ -40,7 +44,7 @@
{% endif %}
{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.26e3688c.min.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.92d4c850.min.css' | url }}">
{% if config.theme.palette %}
{% set palette = config.theme.palette %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.ecc896b0.min.css' | url }}">
@ -216,7 +220,7 @@
"base": base_url,
"features": features,
"translations": {},
"search": "assets/javascripts/workers/search.74e28a9f.min.js" | url
"search": "assets/javascripts/workers/search.780af0f4.min.js" | url
} -%}
{%- if config.extra.version -%}
{%- set _ = app.update({ "version": config.extra.version }) -%}
@ -244,7 +248,7 @@
</script>
{% endblock %}
{% block scripts %}
<script src="{{ 'assets/javascripts/bundle.9d591707.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.f11ae8b1.min.js' | url }}"></script>
{% for path in config.extra_javascript %}
{% if path.endswith(".mjs") %}
<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/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": "頁首",
"meta.comments": "評論",
"meta.source": "來源",
"search.config.lang": "ja",
"search.config.pipeline": "stemmer",
"search.config.separator": "[\\s\\-,。]+",
"search.config.separator": "[\\s\\u200b\\u3000\\-、。,.?!;]+",
"nav": "導航",
"readtime.one": "需要 1 分鐘閲讀",
"readtime.other": "需要 # 分鐘閲讀",

View File

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

View File

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

View File

@ -1,63 +1,105 @@
{#-
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">
{{ ref.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(ref) }}
{% if nav_item.children | length > 0 %}
<span class="md-nav__icon md-icon"></span>
{% endif %}
</a>
{% endif %}
{% endmacro %}
{% macro render(nav_item, path, level) %}
{% set class = "md-nav__item" %}
{% if nav_item.active %}
{% set class = class ~ " md-nav__item--active" %}
{% endif %}
{% if nav_item.children %}
{% 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 %}
{% 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">
{% 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 = [] %}
{% 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 %}
{% if not indexes %}
<label class="md-nav__link" for="{{ path }}" id="{{ path }}_label" tabindex="0">
{{ nav_item.title }}
<span class="md-nav__icon md-icon"></span>
</label>
{% else %}
{% set index = indexes | first %}
{% set class = "md-nav__link--active" if index == page %}
<div class="md-nav__link md-nav__link--index {{ class }}">
<a href="{{ index.url | url }}">{{ nav_item.title }}</a>
{% if nav_item.children | length > 1 %}
<label for="{{ path }}">
<span class="md-nav__icon md-icon"></span>
</label>
{% endif %}
</div>
{% endif %}
<nav class="md-nav" data-md-level="{{ level }}" aria-labelledby="{{ path }}_label" aria-expanded="{{ nav_item.active | tojson }}">
<label class="md-nav__title" for="{{ path }}">
<span class="md-nav__icon md-icon"></span>
{{ nav_item.title }}
</label>
<ul class="md-nav__list" data-md-scrollfix>
{% for nav_item in nav_item.children %}
{% if not indexes or nav_item != indexes | first %}
{{ render(nav_item, path ~ "_" ~ loop.index, level + 1) }}
{% 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 %}
<label class="md-nav__link" for="{{ path }}" id="{{ path }}_label" tabindex="0">
{{ render_content(nav_item) }}
<span class="md-nav__icon md-icon"></span>
</label>
{% else %}
{% set index = indexes | first %}
{% set class = "md-nav__link--active" if index == page %}
<div class="md-nav__link md-nav__container">
<a href="{{ index.url | url }}" class="md-nav__link {{ class }}">
{{ render_content(index, nav_item) }}
</a>
{% if nav_item.children | length > 1 %}
<label class="md-nav__link {{ class }}" for="{{ path }}">
<span class="md-nav__icon md-icon"></span>
</label>
{% endif %}
{% endfor %}
</ul>
</nav>
</div>
{% endif %}
<nav class="md-nav" data-md-level="{{ level }}" aria-labelledby="{{ path }}_label" aria-expanded="{{ nav_item.active | tojson }}">
<label class="md-nav__title" for="{{ path }}">
<span class="md-nav__icon md-icon"></span>
{{ nav_item.title }}
</label>
<ul class="md-nav__list" data-md-scrollfix>
{% for nav_item in nav_item.children %}
{% if not indexes or nav_item != indexes | first %}
{{ render(nav_item, path ~ "_" ~ loop.index, level + 1) }}
{% endif %}
{% endfor %}
</ul>
</nav>
{% else %}
{{ render_pruned(nav_item) }}
{% endif %}
</li>
{% elif nav_item == page %}
<li class="{{ class }}">
@ -69,12 +111,12 @@
{% endif %}
{% if 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>
</label>
{% endif %}
<a href="{{ nav_item.url | url }}" class="md-nav__link md-nav__link--active">
{{ nav_item.title }}
{{ render_content(nav_item) }}
</a>
{% if toc %}
{% include "partials/toc.html" %}
@ -83,9 +125,8 @@
{% else %}
<li class="{{ class }}">
<a href="{{ nav_item.url | url }}" class="md-nav__link">
{{ nav_item.title }}
{{ render_content(nav_item) }}
</a>
</li>
{% endif %}
{% endmacro %}
{{ render(nav_item, path, level) }}

View File

@ -1,6 +1,7 @@
{#-
This file was automatically generated - do not edit
-#}
{% import "partials/nav-item.html" as item with context %}
{% set class = "md-nav md-nav--primary" %}
{% if "navigation.tabs" in features %}
{% set class = class ~ " md-nav--lifted" %}
@ -23,8 +24,7 @@
<ul class="md-nav__list" data-md-scrollfix>
{% for nav_item in nav %}
{% set path = "__nav_" ~ loop.index %}
{% set level = 1 %}
{% include "partials/nav-item.html" %}
{{ item.render(nav_item, path, 1) }}
{% endfor %}
</ul>
</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
-#}
{% 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" %}
{% if nav_item.active %}
{% if ref.active %}
{% set class = class ~ " md-tabs__link--active" %}
{% endif %}
{% endif %}
{% if nav_item.children %}
{% set title = title | d(nav_item.title) %}
{% set nav_item = nav_item.children | first %}
{% if nav_item.children %}
{% include "partials/tabs-item.html" %}
{% set first = nav_item.children | first %}
{% if first.children %}
{{ render(first, ref) }}
{% else %}
<li class="md-tabs__item">
<a href="{{ first.url | url }}" class="{{ class }}">
{{ render_content(first, ref) }}
</a>
</li>
{% endif %}
{% else %}
<li class="md-tabs__item">
<a href="{{ nav_item.url | url }}" class="{{ class }}">
{{ title }}
{{ render_content(nav_item) }}
</a>
</li>
{% endif %}
{% else %}
<li class="md-tabs__item">
<a href="{{ nav_item.url | url }}" class="{{ class }}">
{{ nav_item.title }}
</a>
</li>
{% endif %}
{% endmacro %}

View File

@ -1,12 +1,12 @@
{#-
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">
<div class="md-grid">
<ul class="md-tabs__list">
{% for nav_item in nav %}
{% include "partials/tabs-item.html" %}
{{ item.render(nav_item) }}
{% endfor %}
</ul>
</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 io import BytesIO
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.structure.files import get_files
from pkg_resources import get_distribution, working_set
from zipfile import ZipFile, ZIP_DEFLATED
from material.plugins.info.config import InfoConfig
# -----------------------------------------------------------------------------
# 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
class InfoPlugin(BasePlugin[InfoPluginConfig]):
class InfoPlugin(BasePlugin[InfoConfig]):
# Determine whether we're serving
def on_startup(self, *, command, dirty):
@ -235,5 +222,4 @@ def _size(value, factor = 1):
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs")
log.addFilter(DuplicateFilter())
log = logging.getLogger("mkdocs.material.info")

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
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 material.plugins.offline.config import OfflineConfig
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Offline plugin configuration scheme
class OfflinePluginConfig(Config):
enabled = opt.Type(bool, default = True)
# -----------------------------------------------------------------------------
# Offline plugin
class OfflinePlugin(BasePlugin[OfflinePluginConfig]):
class OfflinePlugin(BasePlugin[OfflineConfig]):
# Initialize plugin
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,21 @@ import regex as re
from html import escape
from html.parser import HTMLParser
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.contrib.search import LangOption
from mkdocs.plugins import BasePlugin
from material.plugins.search.config import SearchConfig
try:
import jieba
except ImportError:
jieba = None
# -----------------------------------------------------------------------------
# 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
class SearchPlugin(BasePlugin[SearchPluginConfig]):
class SearchPlugin(BasePlugin[SearchConfig]):
# Determine whether we're running under dirty reload
def on_startup(self, *, command, dirty):
@ -85,6 +72,30 @@ class SearchPlugin(BasePlugin[SearchPluginConfig]):
# Initialize search index
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
def on_page_context(self, context, *, page, config, nav):
self.search_index.add_entry_from_context(page)
@ -167,9 +178,10 @@ class SearchIndex:
title = "".join(section.title).strip()
text = "".join(section.text).strip()
# Reset text, if only titles should be indexed
if self.config["indexing"] == "titles":
text = ""
# Segment Chinese characters if jieba is available
if jieba:
title = self._segment_chinese(title)
text = self._segment_chinese(text)
# Create entry for section
entry = {
@ -252,6 +264,25 @@ class SearchIndex:
# No item found
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
@ -341,7 +372,8 @@ class Parser(HTMLParser):
self.keep = set([
"p", # Paragraphs
"code", "pre", # Code blocks
"li", "ol", "ul" # Lists
"li", "ol", "ul", # Lists
"sub", "sup" # Sub- and superscripts
])
# Current context and section
@ -362,7 +394,7 @@ class Parser(HTMLParser):
else:
return
# Handle headings
# Handle heading
if tag in ([f"h{x}" for x in range(1, 7)]):
depth = len(self.context)
if "id" in attrs:
@ -507,23 +539,22 @@ class Parser(HTMLParser):
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs")
log.addFilter(DuplicateFilter())
log = logging.getLogger("mkdocs.material.search")
# Tags that are self-closing
void = set([
"area", # Image map areas
"base", # Document base
"br", # Line breaks
"col", # Table columns
"embed", # External content
"hr", # Horizontal rules
"img", # Images
"input", # Input fields
"link", # Links
"meta", # Metadata
"param", # External parameters
"source", # Image source sets
"track", # Text track
"wbr" # Line break opportunities
"area", # Image map areas
"base", # Document base
"br", # Line breaks
"col", # Table columns
"embed", # External content
"hr", # Horizontal rules
"img", # Images
"input", # Input fields
"link", # Links
"meta", # Metadata
"param", # External parameters
"source", # Image source sets
"track", # Text track
"wbr" # Line break opportunities
])

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 re
import requests
import sys
from cairosvg import svg2png
from collections import defaultdict
from hashlib import md5
from io import BytesIO
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 PIL import Image, ImageDraw, ImageFont
from shutil import copyfile
from tempfile import TemporaryFile
from zipfile import ZipFile
try:
from cairosvg import svg2png
from PIL import Image, ImageDraw, ImageFont
dependencies = True
except ImportError:
dependencies = False
from material.plugins.social.config import SocialConfig
# -----------------------------------------------------------------------------
# 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
class SocialPlugin(BasePlugin[SocialPluginConfig]):
class SocialPlugin(BasePlugin[SocialConfig]):
def __init__(self):
self._executor = concurrent.futures.ThreadPoolExecutor(4)
@ -104,14 +74,6 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]):
value = self.config.cards_font
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
if not config.site_url:
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 markdown.extensions.toc import slugify
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
# 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
# -----------------------------------------------------------------------------
# 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
class TagsPlugin(BasePlugin[TagsPluginConfig]):
class TagsPlugin(BasePlugin[TagsConfig]):
supports_multiple_instances = True
# Initialize plugin
@ -166,5 +159,4 @@ class TagsPlugin(BasePlugin[TagsPluginConfig]):
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs")
log.addFilter(DuplicateFilter())
log = logging.getLogger("mkdocs.material.tags")

View File

@ -57,6 +57,7 @@ Issues = "https://github.com/squidfunk/mkdocs-material/issues"
Funding = "https://github.com/sponsors/squidfunk"
[project.entry-points."mkdocs.plugins"]
"material/blog" = "material.plugins.blog.plugin:BlogPlugin"
"material/info" = "material.plugins.info.plugin:InfoPlugin"
"material/offline" = "material.plugins.offline.plugin:OfflinePlugin"
"material/search" = "material.plugins.search.plugin:SearchPlugin"

View File

@ -27,6 +27,10 @@ pygments>=2.14
pymdown-extensions>=9.9.1
# Requirements for plugins
babel>=2.10.3
colorama>=0.4
lxml>=4.6
paginate>=0.5.6
regex>=2022.4.24
readtime>=2.0
requests>=2.26

View File

@ -27,11 +27,38 @@ import {
fromEvent,
map,
merge,
shareReplay,
startWith
} from "rxjs"
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
* ------------------------------------------------------------------------- */
@ -39,14 +66,6 @@ import { getActiveElement } from "../_"
/**
* 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
*
* @returns Element focus observable
@ -54,19 +73,9 @@ import { getActiveElement } from "../_"
export function watchElementFocus(
el: HTMLElement
): Observable<boolean> {
return merge(
fromEvent(document.body, "focusin"),
fromEvent(document.body, "focusout")
)
return observer$
.pipe(
debounceTime(1),
map(() => {
const active = getActiveElement()
return typeof active !== "undefined"
? el.contains(active)
: false
}),
startWith(el === getActiveElement()),
map(active => el.contains(active)),
distinctUntilChanged()
)
}

View File

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

View File

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

View File

@ -102,7 +102,7 @@ export function watchAnnotation(
map(([{ x, y }, scroll]): ElementOffset => {
const { width, height } = getElementSize(el)
return ({
x: x - scroll.x + width / 2,
x: x - scroll.x + width / 2,
y: y - scroll.y + height / 2
})
})

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

View File

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

View File

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

View File

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

View File

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

View File

@ -110,7 +110,7 @@ export function setupInstantLoading(
return EMPTY
// 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")
if (el === null)
return EMPTY
@ -151,7 +151,7 @@ export function setupInstantLoading(
)
// 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))
.subscribe(() => {
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
// 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 document$ = response$
.pipe(
@ -253,7 +253,7 @@ export function setupInstantLoading(
}
// 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")
return concat(getElements("script", container))
.pipe(
@ -284,7 +284,7 @@ export function setupInstantLoading(
)
// 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")
popstate$.pipe(map(getLocation))
.subscribe(location$)

View File

@ -37,6 +37,7 @@ import {
SearchQueryTerms,
getSearchQueryTerms,
parseSearchQuery,
segment,
transformSearchQuery
} from "../query"
@ -204,6 +205,14 @@ export class Search {
* @returns Search result
*/
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)
if (!query)
return { items: [] }

View File

@ -21,4 +21,5 @@
*/
export * from "./_"
export * from "./segment"
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/typeset";
@import "main/components/author";
@import "main/components/banner";
@import "main/components/base";
@import "main/components/clipboard";
@ -51,11 +52,15 @@
@import "main/components/footer";
@import "main/components/form";
@import "main/components/header";
@import "main/components/meta";
@import "main/components/nav";
@import "main/components/pagination";
@import "main/components/post";
@import "main/components/search";
@import "main/components/select";
@import "main/components/sidebar";
@import "main/components/source";
@import "main/components/status";
@import "main/components/tabs";
@import "main/components/tag";
@import "main/components/tooltip";

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
&__link {
display: flex;
align-items: center;
justify-content: space-between;
align-items: flex-start;
margin-top: 0.625em;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: color 125ms;
scroll-snap-align: start;
@ -109,17 +105,43 @@
// Active link
.md-nav__item &--active {
color: var(--md-typeset-a-color);
// Also enable color transitions on inline code blocks
&,
code {
color: var(--md-typeset-a-color);
}
}
// Stretch section index link to full width
.md-nav__item &--index [href] {
width: 100%;
// Navigation link title
.md-ellipsis {
// Hack: Safari exhibits a bug where the text will sometimes disappear
// and the element will become unclickable. Setting `position: relative`
// seems to fix the issue - see https://bit.ly/3HljM1T
position: relative;
}
// Always align navigation icons to the right
.md-icon:last-child {
margin-left: auto;
}
// Navigation link icon
svg {
flex-shrink: 0;
height: 1.3em;
fill: currentcolor;
// Adjust spacing of next child
+ * {
margin-inline-start: px2rem(8px);
}
}
// Navigation link on focus/hover
&:is(:focus, :hover) {
&:not(.md-nav__container):is(:focus, :hover) {
color: var(--md-accent-fg-color);
cursor: pointer;
}
// Show outline for keyboard devices
@ -146,11 +168,15 @@
display: none;
}
}
}
// Navigation link children (for section indexes)
> * {
display: flex;
cursor: pointer;
// Navigation container (for section index pages)
&__container > .md-nav__link {
margin-top: 0;
// Stretch first child
&:first-child {
flex-grow: 1;
}
}
@ -283,6 +309,16 @@
padding: px2rem(12px) px2rem(16px);
margin-top: 0;
// Navigation link icon
svg {
margin-top: 0.1em;
}
// Adjust spacing on nested link
> .md-nav__link {
padding: 0;
}
// Navigation icon
.md-nav__icon {
width: px2rem(24px);
@ -515,16 +551,15 @@
// Show navigation link as title
> .md-nav__link {
font-weight: 700;
pointer-events: none;
// Make labels discernable from links
&[for] {
color: var(--md-default-fg-color--light);
}
// Make navigation link clickable
&--index [href] {
pointer-events: initial;
// Omit clicks if not a section index page
&:not(.md-nav__container) {
pointer-events: none;
}
// Hide naviation icon
@ -613,8 +648,8 @@
background: 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
&:not(.md-nav__link--index) {
// Omit clicks if not a section index page
&:not(.md-nav__container) {
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
&__list {
display: flex;
padding: 0;
margin: 0;
margin-inline-start: px2rem(4px);
@ -74,7 +75,6 @@
// Navigation tabs item
&__item {
display: inline-block;
height: px2rem(48px);
padding-inline: px2rem(12px);
}
@ -82,7 +82,7 @@
// Navigation tabs link - could be defined as block elements and aligned via
// line height, but this would imply more repaints when scrolling
&__link {
display: block;
display: flex;
margin-top: px2rem(16px);
font-size: px2rem(14px);
outline-color: var(--md-accent-fg-color);
@ -101,6 +101,13 @@
opacity: 1;
}
// Navigation tabs link icon
svg {
height: 1.3em;
margin-inline-end: px2rem(8px);
fill: currentcolor;
}
// Delay transitions by a small amount
@for $i from 2 through 16 {
.md-tabs__item:nth-child(#{$i}) & {

View File

@ -27,17 +27,14 @@
// Continuous pulse animation
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 var(--md-default-fg-color--lightest);
transform: scale(0.95);
}
75% {
box-shadow: 0 0 0 px2em(10px) transparent;
transform: scale(1);
}
100% {
box-shadow: 0 0 0 0 transparent;
transform: scale(0.95);
}
}
@ -48,6 +45,8 @@
// Tooltip variables
:root {
--md-annotation-bg-icon: svg-load("material/circle.svg");
--md-annotation-icon: svg-load("material/plus-circle.svg");
--md-tooltip-width: #{px2rem(400px)};
}
@ -124,6 +123,7 @@
.md-annotation {
font-weight: 400;
white-space: normal;
vertical-align: text-bottom;
outline: none;
// Adjust for right-to-left languages
@ -131,125 +131,156 @@
direction: rtl;
}
// Annotation index in code block
code & {
font-family: var(--md-code-font-family);
font-size: inherit;
}
// 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;
line-height: 1.25;
}
// Annotation index
&__index {
position: relative;
z-index: 0;
margin: 0 1ch;
font-family: var(--md-code-font-family);
font-size: px2em(13.6px, 16px);
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
// Hack: increase specificity to override default for anchors in typesetted
// content, because transitions are defined on anchor elements
.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;
// [screen]: Render annotation markers as icons
@media screen {
width: 2.2ch;
// [reduced motion]: Disable animation
@media not all and (prefers-reduced-motion) {
// Annotation marker is visible
[data-md-visible] > & {
animation: pulse 2000ms infinite;
}
// Annotation 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 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 index in code block
code & {
font-family: var(--md-code-font-family);
font-size: inherit;
}
// Annotation index for active tooltip or on hover
:is(.md-tooltip--active + &, :hover > &) {
color: var(--md-accent-bg-color);
// Annotation marker
// 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 {
background-color: var(--md-accent-fg-color);
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);
}
}
}
// Annotation index for active tooltip
.md-tooltip--active + & {
z-index: 2;
transition: none;
animation: none;
transition-duration: 0ms;
animation-play-state: paused;
}
// Annotation marker
[data-md-annotation-id] {
display: inline-block;
line-height: 90%;
// Annotation marker content
&::before {
display: inline-block;
padding-bottom: 0.1em;
vertical-align: 0.065em;
content: attr(data-md-annotation-id);
transition: transform 400ms cubic-bezier(0.1, 0.7, 0.1, 1);
transform: scale(1.15);
// [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;
// [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
:focus-within > & {
transform: scale(1.25) rotate(45deg);
}
// Annotation marker content
&::after {
content: attr(data-md-annotation-id);
}
}
}
}
}
// ----------------------------------------------------------------------------
// Scoped in typesetted content to match specificity of regular content
.md-typeset {
// 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 }}" />
{% 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 -->
<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 %}
<!-- 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": "頁首",
"meta.comments": "評論",
"meta.source": "來源",
"search.config.lang": "ja",
"search.config.pipeline": "stemmer",
"search.config.separator": "[\\s\\-,。]+",
"search.config.separator": "[\\s\\u200b\\u3000\\-、。,.?!;]+",
"nav": "導航",
"readtime.one": "需要 1 分鐘閲讀",
"readtime.other": "需要 # 分鐘閲讀",

View File

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

View File

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

View File

@ -20,103 +20,177 @@
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">
{{ ref.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(ref) }}
<!-- Only render toggle if there's at least one nested item -->
{% if nav_item.children | length > 0 %}
<span class="md-nav__icon md-icon"></span>
{% endif %}
</a>
{% endif %}
{% endmacro %}
<!-- Render navigation item -->
{% macro render(nav_item, path, level) %}
<!-- Determine class according to state -->
<!-- Determine base classes -->
{% set class = "md-nav__item" %}
{% if nav_item.active %}
{% set class = class ~ " md-nav__item--active" %}
{% endif %}
<!-- Main navigation item with nested items -->
<!-- Navigation item with nested items -->
{% 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 -->
{% if "navigation.sections" in features and level == 1 + (
"navigation.tabs" in features
) %}
{% 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 %}
<!-- Render item with nested items -->
<!-- Nested navigation item -->
<li class="{{ class }} md-nav__item--nested">
{% set expanded = "navigation.expand" in features %}
{% set active = nav_item.active or expanded %}
{% if not prune %}
{% set expanded = "navigation.expand" in features %}
{% set active = nav_item.active or expanded %}
<!-- Determine checked and indeterminate state -->
{% set checked = "checked" if nav_item.active %}
{% if expanded and not checked %}
{% set indeterminate = "md-toggle--indeterminate" %}
{% endif %}
<!-- Determine checked and indeterminate state -->
{% set checked = "checked" if nav_item.active %}
{% if expanded and not checked %}
{% set indeterminate = "md-toggle--indeterminate" %}
{% endif %}
<!-- Active checkbox expands items contained within nested section -->
<input
class="md-nav__toggle md-toggle {{ indeterminate }}"
type="checkbox"
id="{{ path }}"
{{ checked }}
/>
<!-- Active checkbox expands items contained within nested section -->
<input
class="md-nav__toggle md-toggle {{ indeterminate }}"
type="checkbox"
id="{{ path }}"
{{ checked }}
/>
<!-- 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 %}
<!-- Toggle to expand nested items -->
{% if not indexes %}
<label
class="md-nav__link"
for="{{ path }}"
id="{{ path }}_label"
tabindex="0"
>
{{ render_content(nav_item) }}
<span class="md-nav__icon md-icon"></span>
</label>
<!-- Render toggle to expand nested items -->
{% if not indexes %}
<label
class="md-nav__link"
for="{{ path }}"
id="{{ path }}_label"
tabindex="0"
>
{{ nav_item.title }}
<span class="md-nav__icon md-icon"></span>
</label>
<!-- Toggle to expand nested items with link to index page -->
{% else %}
{% set index = indexes | first %}
{% set class = "md-nav__link--active" if index == page %}
<div class="md-nav__link md-nav__container">
<a
href="{{ index.url | url }}"
class="md-nav__link {{ class }}"
>
{{ render_content(index, nav_item) }}
</a>
<!-- Render link to index page + toggle -->
{% else %}
{% set index = indexes | first %}
{% set class = "md-nav__link--active" if index == page %}
<div class="md-nav__link md-nav__link--index {{ class }}">
<a href="{{ index.url | url }}">{{ nav_item.title }}</a>
<!-- Only render toggle if there's at least one more page -->
{% if nav_item.children | length > 1 %}
<label for="{{ path }}">
<span class="md-nav__icon md-icon"></span>
</label>
{% endif %}
</div>
{% endif %}
<!-- Render nested navigation -->
<nav
class="md-nav"
data-md-level="{{ level }}"
aria-labelledby="{{ path }}_label"
aria-expanded="{{ nav_item.active | tojson }}"
>
<label class="md-nav__title" for="{{ path }}">
<span class="md-nav__icon md-icon"></span>
{{ nav_item.title }}
</label>
<ul class="md-nav__list" data-md-scrollfix>
<!-- Render nested item list -->
{% for nav_item in nav_item.children %}
{% if not indexes or nav_item != indexes | first %}
{{ render(nav_item, path ~ "_" ~ loop.index, level + 1) }}
<!-- Only render toggle if there's at least one more page -->
{% if nav_item.children | length > 1 %}
<label class="md-nav__link {{ class }}" for="{{ path }}">
<span class="md-nav__icon md-icon"></span>
</label>
{% endif %}
{% endfor %}
</ul>
</nav>
</div>
{% endif %}
<!-- Nested navigation -->
<nav
class="md-nav"
data-md-level="{{ level }}"
aria-labelledby="{{ path }}_label"
aria-expanded="{{ nav_item.active | tojson }}"
>
<label class="md-nav__title" for="{{ path }}">
<span class="md-nav__icon md-icon"></span>
{{ nav_item.title }}
</label>
<ul class="md-nav__list" data-md-scrollfix>
<!-- Nested navigation item -->
{% for nav_item in nav_item.children %}
{% if not indexes or nav_item != indexes | first %}
{{ render(nav_item, path ~ "_" ~ loop.index, level + 1) }}
{% endif %}
{% endfor %}
</ul>
</nav>
<!-- Pruned navigation item -->
{% else %}
{{ render_pruned(nav_item) }}
{% endif %}
</li>
<!-- Currently active page -->
@ -124,7 +198,7 @@
<li class="{{ class }}">
{% set toc = page.toc %}
<!-- Active checkbox expands items contained within nested section -->
<!-- State toggle -->
<input
class="md-nav__toggle md-toggle"
type="checkbox"
@ -137,10 +211,10 @@
{% set toc = first.children %}
{% endif %}
<!-- Render table of contents, if not empty -->
<!-- Navigation link to table of contents -->
{% if 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>
</label>
{% endif %}
@ -148,24 +222,21 @@
href="{{ nav_item.url | url }}"
class="md-nav__link md-nav__link--active"
>
{{ nav_item.title }}
{{ render_content(nav_item) }}
</a>
<!-- Show table of contents -->
<!-- Table of contents -->
{% if toc %}
{% include "partials/toc.html" %}
{% endif %}
</li>
<!-- Main navigation item -->
<!-- Navigation item -->
{% else %}
<li class="{{ class }}">
<a href="{{ nav_item.url | url }}" class="md-nav__link">
{{ nav_item.title }}
{{ render_content(nav_item) }}
</a>
</li>
{% endif %}
{% endmacro %}
<!-- Render current and nested navigation items -->
{{ render(nav_item, path, level) }}

View File

@ -20,7 +20,9 @@
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" %}
{% if "navigation.tabs" in features %}
{% set class = class ~ " md-nav--lifted" %}
@ -29,7 +31,7 @@
{% set class = class ~ " md-nav--integrated" %}
{% endif %}
<!-- Main navigation -->
<!-- Navigation -->
<nav
class="{{ class }}"
aria-label="{{ lang.t('nav') }}"
@ -57,12 +59,11 @@
</div>
{% endif %}
<!-- Render item list -->
<!-- Navigation list -->
<ul class="md-nav__list" data-md-scrollfix>
{% for nav_item in nav %}
{% set path = "__nav_" ~ loop.index %}
{% set level = 1 %}
{% include "partials/nav-item.html" %}
{{ item.render(nav_item, path, 1) }}
{% endfor %}
</ul>
</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.
-->
<!-- Determine class according to state -->
{% if not class %}
<!-- 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 -->
{% set class = "md-tabs__link" %}
{% if nav_item.active %}
{% if ref.active %}
{% set class = class ~ " md-tabs__link--active" %}
{% endif %}
{% endif %}
<!-- Main navigation item with nested items -->
{% if nav_item.children %}
{% set title = title | d(nav_item.title) %}
{% set nav_item = nav_item.children | first %}
<!-- Recurse, if the first item has further nested items -->
<!-- Navigation item with nested items -->
{% if nav_item.children %}
{% include "partials/tabs-item.html" %}
{% set first = nav_item.children | first %}
<!-- Render item -->
<!-- Recurse, if the first item has further nested items -->
{% if first.children %}
{{ render(first, ref) }}
<!-- Nested navigation item -->
{% else %}
<li class="md-tabs__item">
<a href="{{ first.url | url }}" class="{{ class }}">
{{ render_content(first, ref) }}
</a>
</li>
{% endif %}
<!-- Navigation item -->
{% else %}
<li class="md-tabs__item">
<a href="{{ nav_item.url | url }}" class="{{ class }}">
{{ title }}
{{ render_content(nav_item) }}
</a>
</li>
{% endif %}
<!-- Main navigation item -->
{% else %}
<li class="md-tabs__item">
<a href="{{ nav_item.url | url }}" class="{{ class }}">
{{ nav_item.title }}
</a>
</li>
{% endif %}
{% endmacro %}

View File

@ -20,8 +20,7 @@
IN THE SOFTWARE.
-->
<!-- Hack: unset variable, as we're using it recursively in tabs-item.html -->
{% set class = "" %}
{% import "partials/tabs-item.html" as item with context %}
<!-- Navigation tabs -->
<nav
@ -32,7 +31,7 @@
<div class="md-grid">
<ul class="md-tabs__list">
{% for nav_item in nav %}
{% include "partials/tabs-item.html" %}
{{ item.render(nav_item) }}
{% endfor %}
</ul>
</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 io import BytesIO
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.structure.files import get_files
from pkg_resources import get_distribution, working_set
from zipfile import ZipFile, ZIP_DEFLATED
from material.plugins.info.config import InfoConfig
# -----------------------------------------------------------------------------
# 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
class InfoPlugin(BasePlugin[InfoPluginConfig]):
class InfoPlugin(BasePlugin[InfoConfig]):
# Determine whether we're serving
def on_startup(self, *, command, dirty):
@ -235,5 +222,4 @@ def _size(value, factor = 1):
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs")
log.addFilter(DuplicateFilter())
log = logging.getLogger("mkdocs.material.info")

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
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 material.plugins.offline.config import OfflineConfig
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Offline plugin configuration scheme
class OfflinePluginConfig(Config):
enabled = opt.Type(bool, default = True)
# -----------------------------------------------------------------------------
# Offline plugin
class OfflinePlugin(BasePlugin[OfflinePluginConfig]):
class OfflinePlugin(BasePlugin[OfflineConfig]):
# Initialize plugin
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,21 @@ import regex as re
from html import escape
from html.parser import HTMLParser
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.contrib.search import LangOption
from mkdocs.plugins import BasePlugin
from material.plugins.search.config import SearchConfig
try:
import jieba
except ImportError:
jieba = None
# -----------------------------------------------------------------------------
# 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
class SearchPlugin(BasePlugin[SearchPluginConfig]):
class SearchPlugin(BasePlugin[SearchConfig]):
# Determine whether we're running under dirty reload
def on_startup(self, *, command, dirty):
@ -85,6 +72,30 @@ class SearchPlugin(BasePlugin[SearchPluginConfig]):
# Initialize search index
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
def on_page_context(self, context, *, page, config, nav):
self.search_index.add_entry_from_context(page)
@ -167,9 +178,10 @@ class SearchIndex:
title = "".join(section.title).strip()
text = "".join(section.text).strip()
# Reset text, if only titles should be indexed
if self.config["indexing"] == "titles":
text = ""
# Segment Chinese characters if jieba is available
if jieba:
title = self._segment_chinese(title)
text = self._segment_chinese(text)
# Create entry for section
entry = {
@ -252,6 +264,25 @@ class SearchIndex:
# No item found
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
@ -341,7 +372,8 @@ class Parser(HTMLParser):
self.keep = set([
"p", # Paragraphs
"code", "pre", # Code blocks
"li", "ol", "ul" # Lists
"li", "ol", "ul", # Lists
"sub", "sup" # Sub- and superscripts
])
# Current context and section
@ -362,7 +394,7 @@ class Parser(HTMLParser):
else:
return
# Handle headings
# Handle heading
if tag in ([f"h{x}" for x in range(1, 7)]):
depth = len(self.context)
if "id" in attrs:
@ -507,23 +539,22 @@ class Parser(HTMLParser):
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs")
log.addFilter(DuplicateFilter())
log = logging.getLogger("mkdocs.material.search")
# Tags that are self-closing
void = set([
"area", # Image map areas
"base", # Document base
"br", # Line breaks
"col", # Table columns
"embed", # External content
"hr", # Horizontal rules
"img", # Images
"input", # Input fields
"link", # Links
"meta", # Metadata
"param", # External parameters
"source", # Image source sets
"track", # Text track
"wbr" # Line break opportunities
"area", # Image map areas
"base", # Document base
"br", # Line breaks
"col", # Table columns
"embed", # External content
"hr", # Horizontal rules
"img", # Images
"input", # Input fields
"link", # Links
"meta", # Metadata
"param", # External parameters
"source", # Image source sets
"track", # Text track
"wbr" # Line break opportunities
])

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 re
import requests
import sys
from cairosvg import svg2png
from collections import defaultdict
from hashlib import md5
from io import BytesIO
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 PIL import Image, ImageDraw, ImageFont
from shutil import copyfile
from tempfile import TemporaryFile
from zipfile import ZipFile
try:
from cairosvg import svg2png
from PIL import Image, ImageDraw, ImageFont
dependencies = True
except ImportError:
dependencies = False
from material.plugins.social.config import SocialConfig
# -----------------------------------------------------------------------------
# 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
class SocialPlugin(BasePlugin[SocialPluginConfig]):
class SocialPlugin(BasePlugin[SocialConfig]):
def __init__(self):
self._executor = concurrent.futures.ThreadPoolExecutor(4)
@ -104,14 +74,6 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]):
value = self.config.cards_font
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
if not config.site_url:
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 markdown.extensions.toc import slugify
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
# 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
# -----------------------------------------------------------------------------
# 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
class TagsPlugin(BasePlugin[TagsPluginConfig]):
class TagsPlugin(BasePlugin[TagsConfig]):
supports_multiple_instances = True
# Initialize plugin
@ -166,5 +159,4 @@ class TagsPlugin(BasePlugin[TagsPluginConfig]):
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs")
log.addFilter(DuplicateFilter())
log = logging.getLogger("mkdocs.material.tags")