mirror of
https://github.com/squidfunk/mkdocs-material.git
synced 2024-06-14 11:52:32 +03:00
Merge of features tied to 'Piri Piri' funding goal'
This commit is contained in:
parent
03d065ca20
commit
1bee037713
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -23,5 +23,5 @@
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script src="{{ 'assets/javascripts/custom.a678ee80.min.js' | url }}"></script>
|
||||
<script src="{{ 'assets/javascripts/custom.98e0b405.min.js' | url }}"></script>
|
||||
{% endblock %}
|
||||
|
29
material/assets/javascripts/bundle.202856ae.min.js
vendored
Normal file
29
material/assets/javascripts/bundle.202856ae.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
material/assets/javascripts/bundle.202856ae.min.js.map
Normal file
8
material/assets/javascripts/bundle.202856ae.min.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
material/assets/stylesheets/main.08fbbb04.min.css
vendored
Normal file
1
material/assets/stylesheets/main.08fbbb04.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
material/assets/stylesheets/main.08fbbb04.min.css.map
Normal file
1
material/assets/stylesheets/main.08fbbb04.min.css.map
Normal file
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
@ -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.08fbbb04.min.css' | url }}">
|
||||
{% if config.theme.palette %}
|
||||
{% set palette = config.theme.palette %}
|
||||
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.ecc896b0.min.css' | url }}">
|
||||
@ -212,7 +216,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 }) -%}
|
||||
@ -240,7 +244,7 @@
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ 'assets/javascripts/bundle.220ee61c.min.js' | url }}"></script>
|
||||
<script src="{{ 'assets/javascripts/bundle.202856ae.min.js' | url }}"></script>
|
||||
{% for path in config.extra_javascript %}
|
||||
{% if path.endswith(".mjs") %}
|
||||
<script type="module" src="{{ path | url }}"></script>
|
||||
|
16
material/blog-archive.html
Normal file
16
material/blog-archive.html
Normal 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 %}
|
16
material/blog-category.html
Normal file
16
material/blog-category.html
Normal 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
98
material/blog-post.html
Normal 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
19
material/blog.html
Normal 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 %}
|
@ -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 %}
|
||||
|
@ -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": "需要 # 分鐘閲讀",
|
||||
|
@ -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": "清除",
|
||||
|
@ -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": "清空当前内容",
|
||||
|
@ -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">
|
||||
{{ nav_item.title }}
|
||||
</span>
|
||||
{% if nav_item.is_page and nav_item.meta.status %}
|
||||
{{ render_status(nav_item, nav_item.meta.status) }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
{% macro render_pruned(nav_item, ref = nav_item) %}
|
||||
{% set first = nav_item.children | first %}
|
||||
{% if first and first.children %}
|
||||
{{ render_pruned(first, ref) }}
|
||||
{% else %}
|
||||
<a href="{{ first.url | url }}" class="md-nav__link">
|
||||
{{ render_content(first, ref) }}
|
||||
{% if nav_item.children | length > 1 %}
|
||||
<span class="md-nav__icon md-icon"></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
{% macro render(nav_item, path, level) %}
|
||||
{% 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) }}
|
||||
|
@ -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>
|
||||
|
20
material/partials/pagination.html
Normal file
20
material/partials/pagination.html
Normal 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>
|
60
material/partials/post.html
Normal file
60
material/partials/post.html
Normal 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>
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
0
material/plugins/blog/__init__.py
Normal file
0
material/plugins/blog/__init__.py
Normal file
82
material/plugins/blog/config.py
Normal file
82
material/plugins/blog/config.py
Normal 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
material/plugins/blog/plugin.py
Normal file
887
material/plugins/blog/plugin.py
Normal 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")
|
36
material/plugins/info/config.py
Normal file
36
material/plugins/info/config.py
Normal 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)
|
@ -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")
|
||||
|
30
material/plugins/offline/config.py
Normal file
30
material/plugins/offline/config.py
Normal 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)
|
@ -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):
|
||||
|
51
material/plugins/search/config.py
Normal file
51
material/plugins/search/config.py
Normal 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")
|
@ -26,34 +26,22 @@ 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.config.config_options import SubConfig
|
||||
from mkdocs.plugins import BasePlugin
|
||||
|
||||
from material.plugins.search.config import SearchConfig, SearchFieldConfig
|
||||
|
||||
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 +73,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 +179,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 +265,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 +373,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 +395,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 +540,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
|
||||
])
|
||||
|
@ -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)
|
48
material/plugins/social/config.py
Normal file
48
material/plugins/social/config.py
Normal 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'"
|
||||
)
|
@ -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(
|
||||
|
@ -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()
|
43
material/plugins/tags/config.py
Normal file
43
material/plugins/tags/config.py
Normal 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 = [])
|
@ -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")
|
||||
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -20,7 +20,6 @@
|
||||
* IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import "iframe-worker/shim"
|
||||
import {
|
||||
Observable,
|
||||
Subject,
|
||||
|
@ -22,6 +22,7 @@
|
||||
|
||||
import "array-flat-polyfill"
|
||||
import "focus-visible"
|
||||
import "iframe-worker/shim"
|
||||
import "unfetch/polyfill"
|
||||
import "url-polyfill"
|
||||
|
||||
|
@ -22,20 +22,26 @@
|
||||
|
||||
import { Observable, merge } from "rxjs"
|
||||
|
||||
import { feature } from "~/_"
|
||||
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 +60,11 @@ import {
|
||||
*/
|
||||
export type Content =
|
||||
| Annotation
|
||||
| ContentTabs
|
||||
| CodeBlock
|
||||
| Mermaid
|
||||
| ContentTabs
|
||||
| DataTable
|
||||
| Details
|
||||
| Mermaid
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Helper types
|
||||
@ -93,6 +99,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$ })),
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
|
@ -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
|
||||
})
|
||||
}
|
@ -21,4 +21,5 @@
|
||||
*/
|
||||
|
||||
export * from "./_"
|
||||
export * from "./block"
|
||||
export * from "./list"
|
||||
|
@ -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()
|
||||
)
|
||||
})
|
||||
|
@ -21,4 +21,3 @@
|
||||
*/
|
||||
|
||||
export * from "./_"
|
||||
export * from "./mermaid"
|
||||
|
@ -31,7 +31,7 @@ import {
|
||||
import { watchScript } from "~/browser"
|
||||
import { h } from "~/utilities"
|
||||
|
||||
import { Component } from "../../../_"
|
||||
import { Component } from "../../_"
|
||||
|
||||
import themeCSS from "./index.css"
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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$)
|
||||
|
@ -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: [] }
|
||||
|
@ -21,4 +21,5 @@
|
||||
*/
|
||||
|
||||
export * from "./_"
|
||||
export * from "./segment"
|
||||
export * from "./transform"
|
||||
|
@ -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
|
||||
}
|
@ -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";
|
||||
|
86
src/assets/stylesheets/main/components/_author.scss
Normal file
86
src/assets/stylesheets/main/components/_author.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
67
src/assets/stylesheets/main/components/_meta.scss
Normal file
67
src/assets/stylesheets/main/components/_meta.scss
Normal 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);
|
||||
}
|
@ -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;
|
||||
|
||||
@ -112,14 +108,22 @@
|
||||
color: var(--md-typeset-a-color);
|
||||
}
|
||||
|
||||
// Stretch section index link to full width
|
||||
.md-nav__item &--index [href] {
|
||||
width: 100%;
|
||||
// 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 +150,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 +291,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 +533,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 +630,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;
|
||||
}
|
||||
|
||||
|
85
src/assets/stylesheets/main/components/_pagination.scss
Normal file
85
src/assets/stylesheets/main/components/_pagination.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
161
src/assets/stylesheets/main/components/_post.scss
Normal file
161
src/assets/stylesheets/main/components/_post.scss
Normal 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;
|
||||
}
|
||||
}
|
74
src/assets/stylesheets/main/components/_status.scss
Normal file
74
src/assets/stylesheets/main/components/_status.scss
Normal 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);
|
||||
}
|
||||
}
|
@ -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}) & {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
41
src/blog-archive.html
Normal 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
41
src/blog-category.html
Normal 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
142
src/blog-post.html
Normal 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
46
src/blog.html
Normal 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 %}
|
@ -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 %}
|
||||
|
@ -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": "需要 # 分鐘閲讀",
|
||||
|
@ -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": "清除",
|
||||
|
@ -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": "清空当前内容",
|
||||
|
@ -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">
|
||||
{{ nav_item.title }}
|
||||
</span>
|
||||
|
||||
<!-- Navigation link status -->
|
||||
{% if nav_item.is_page and nav_item.meta.status %}
|
||||
{{ render_status(nav_item, nav_item.meta.status) }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
<!-- Render navigation item (pruned) -->
|
||||
{% macro render_pruned(nav_item, ref = nav_item) %}
|
||||
{% set first = nav_item.children | first %}
|
||||
|
||||
<!-- Recurse, if the first item has further nested items -->
|
||||
{% if first and first.children %}
|
||||
{{ render_pruned(first, ref) }}
|
||||
|
||||
<!-- Navigation link -->
|
||||
{% else %}
|
||||
<a href="{{ first.url | url }}" class="md-nav__link">
|
||||
{{ render_content(first, ref) }}
|
||||
|
||||
<!-- Only render toggle if there's at least one more page -->
|
||||
{% if nav_item.children | length > 1 %}
|
||||
<span class="md-nav__icon md-icon"></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
<!-- Render navigation item -->
|
||||
{% macro render(nav_item, path, level) %}
|
||||
|
||||
<!-- 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) }}
|
||||
|
@ -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>
|
||||
|
42
src/partials/pagination.html
Normal file
42
src/partials/pagination.html
Normal 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
99
src/partials/post.html
Normal 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>
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
0
src/plugins/blog/__init__.py
Normal file
0
src/plugins/blog/__init__.py
Normal file
82
src/plugins/blog/config.py
Normal file
82
src/plugins/blog/config.py
Normal 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
887
src/plugins/blog/plugin.py
Normal 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")
|
36
src/plugins/info/config.py
Normal file
36
src/plugins/info/config.py
Normal 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)
|
@ -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")
|
||||
|
30
src/plugins/offline/config.py
Normal file
30
src/plugins/offline/config.py
Normal 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)
|
@ -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):
|
||||
|
51
src/plugins/search/config.py
Normal file
51
src/plugins/search/config.py
Normal 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")
|
@ -26,34 +26,22 @@ 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.config.config_options import SubConfig
|
||||
from mkdocs.plugins import BasePlugin
|
||||
|
||||
from material.plugins.search.config import SearchConfig, SearchFieldConfig
|
||||
|
||||
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 +73,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 +179,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 +265,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 +373,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 +395,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 +540,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
|
||||
])
|
||||
|
@ -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)
|
48
src/plugins/social/config.py
Normal file
48
src/plugins/social/config.py
Normal 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'"
|
||||
)
|
@ -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(
|
||||
|
@ -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()
|
43
src/plugins/tags/config.py
Normal file
43
src/plugins/tags/config.py
Normal 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 = [])
|
@ -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")
|
||||
|
Loading…
Reference in New Issue
Block a user