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

Merge pull request #5683 from squidfunk/merge/piri-piri

Features tied to 'Piri Piri' funding goal
This commit is contained in:
Martin Donath 2023-08-21 19:23:39 +02:00 committed by GitHub
commit 46ca6c5418
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
173 changed files with 6667 additions and 1299 deletions

View File

@ -1,3 +1,52 @@
mkdocs-material-9.2.0 (2023-08-21)
Additions and improvements
* Added blogging support via built-in blog plugin
* Added support for Chinese language segmentaiton in search plugin
* Added support for adding custom dates to blog posts
* Added support for paginating archive and category pages
* Added support for annotations (outside of code blocks)
* Added support for navigation icons
* Added support for navigation pruning
* Added support for navigation status
* Added support for customizing site icons
* Added support for customizing (code) annotation icons
* Added focus outline to admonitions and details
* Added prompt for bug report name to info plugin
* Added Luxembourgish translations
* Improved rendering of (code) annotation markers
* Improved print styles for (code) annotations
* Improved customizability of navigation tabs
* Improved interop of plugins with external tools like mike
* Improved interop of blog plugin with awesome pages plugin
* Improved header partial by moving buttons into separate partials
* Improved clarity of site_url warning in social plugin
* Improved blog plugin to automatically setup directory structure
* Switched info plugin to importlib to mitigate deprecations
* Automatically download ResizeObserver polyfill when necessary
* Automatically add iframe-worker polyfill when necessary in offline plugin
* Automatically focus and bring up keyboard on touch devices
* Updated Serbo-Croatian translations
* Updated MkDocs to 1.5.2
Removals
* Removed Universal Analytics integration
* Removed ancient polyfills to reduce size of bundled JavaScript by 20%
* Removed necessity for Array.flat and Array.flatMap polyfill
* Removed announcement bar button when JavaScript is not available
Fixes
* Fixed rendering of tags when announcement bar is present
* Fixed tags plugin rendering pages excluded by other plugins
* Fixed #5132: Blog plugin requires nav entry in mkdocs.yml
* Fixed #5599: Insufficient contrast for default link color
* Fixed #5715: Blog plugin missing integrated table of contents in pagination
* Fixed #5806: Version selector not hoverable on some Android devices
* Fixed #5826: Blog post drafts with tags show up in tags index
mkdocs-material-9.1.21+insiders-4.39.0 (2023-08-01)
* Added support for hoisting theme media files when building projects
@ -46,6 +95,24 @@ mkdocs-material-9.1.18+insiders-4.37.0 (2023-07-07)
* Fixed jumping of content tabs anchor links when instant loading is enabled
* Fixed #5676: Optimize plugin doesn't check for pngquant
mkdocs-material-9.2.0 (2023-07-06)
* Added blogging support via built-in blog plugin
* Added Chinese language support to built-in search plugin
* Added support for annotations (outside of code blocks)
* Added support for navigation icons
* Added support for navigation pruning
* Added support for navigation status
* Added support for customizing site icons
* Improved rendering of (code) annotation markers
* Improved print styles for (code) annotations
* Improved customizability of navigation tabs
* Removed Universal Analytics integration
* Removed ancient polyfills to reduce size of bundled JavaScript by 20%
* Removed necessity for Array.flat and Array.flatMap polyfill
* Removed announcement bar button when JavaScript is not available
* Automatically download ResizeObserver polyfill when necessary
mkdocs-material-9.1.18 (2023-07-03)
* Updated Danish translations

View File

@ -47,6 +47,8 @@ check the distribution of browser types and versions among your users.
[open an issue]: https://github.com/squidfunk/mkdocs-material/issues/new/choose
[caniuse.com]: https://caniuse.com/
[:is pseudo selector]: https://caniuse.com/css-matches-pseudo
[browser support]: #supported-browsers
[built-in privacy plugin]: setup/ensuring-data-privacy.md#built-in-privacy-plugin
## Other browsers

View File

@ -2,6 +2,55 @@
## Material for MkDocs
### 9.2.0 <small>July 6, 2023</small> { id="9.2.0" }
__Additions and improvements__
- Added blogging support via built-in blog plugin
- Added support for Chinese language segmentaiton in search plugin
- Added support for adding custom dates to blog posts
- Added support for paginating archive and category pages
- Added support for annotations (outside of code blocks)
- Added support for navigation icons
- Added support for navigation pruning
- Added support for navigation status
- Added support for customizing site icons
- Added support for customizing (code) annotation icons
- Added focus outline to admonitions and details
- Added prompt for bug report name to info plugin
- Added Luxembourgish translations
- Improved rendering of (code) annotation markers
- Improved print styles for (code) annotations
- Improved customizability of navigation tabs
- Improved interop of plugins with external tools like mike
- Improved interop of blog plugin with awesome pages plugin
- Improved header partial by moving buttons into separate partials
- Improved clarity of `site_url` warning in social plugin
- Improved blog plugin to automatically setup directory structure
- Switched info plugin to `importlib` to mitigate deprecations
- Automatically download ResizeObserver polyfill when necessary
- Automatically add iframe-worker polyfill when necessary in offline plugin
- Automatically focus and bring up keyboard on touch devices
- Updated Serbo-Croatian translations
- Updated MkDocs to 1.5.2
__Removals__
- Removed Universal Analytics integration
- Removed ancient polyfills to reduce size of bundled JavaScript by 20%
- Removed necessity for `Array.flat` and `Array.flatMap` polyfill
- Removed announcement bar button when JavaScript is not available
__Fixes__
- Fixed rendering of tags when announcement bar is present
- Fixed tags plugin rendering pages excluded by other plugins
- Fixed #5132: Blog plugin requires `nav` entry in `mkdocs.yml`
- Fixed #5599: Insufficient contrast for default link color
- Fixed #5715: Blog plugin missing integrated table of contents in pagination
- Fixed #5806: Version selector not hoverable on some Android devices
- Fixed #5826: Blog post drafts with tags show up in tags index
### 9.1.21 <small>July 27, 2023</small> { id="9.1.20" }
- Fixed MkDocs 1.4 compat issue in social plugin (9.1.20 regression)

View File

@ -100,6 +100,7 @@ assets may also be put in the `overrides` directory:
│ │ └─ analytics.html # Analytics setup
│ ├─ languages/ # Translation languages
│ ├─ actions.html # Actions
│ ├─ alternate.html # Site language selector
│ ├─ comments.html # Comment system (empty by default)
│ ├─ consent.html # Consent
│ ├─ content.html # Page content
@ -113,6 +114,7 @@ assets may also be put in the `overrides` directory:
│ ├─ nav.html # Main navigation
│ ├─ nav-item.html # Main navigation item
│ ├─ pagination.html # Pagination (used for blog)
│ ├─ palette.html # Color palette toggle
│ ├─ post.html # Blog post excerpt
│ ├─ search.html # Search interface
│ ├─ social.html # Social links

View File

@ -31,11 +31,51 @@ See additional configuration options:
[Markdown in HTML]: ../setup/extensions/python-markdown.md#markdown-in-html
[SuperFences]: ../setup/extensions/python-markdown-extensions.md#superfences
### Annotation icons
[:octicons-tag-24: 9.2.0][Annotation icons support]
The annotation icon can be changed to any icon bundled with the theme, or even
a [custom icon], e.g. to material/arrow-right-circle:. Simply add the following
lines to `mkdocs.yml`:
``` yaml
theme:
icon:
annotation: material/arrow-right-circle # (1)!
```
1. Enter a few keywords to find the perfect icon using our [icon search] and
click on the shortcode to copy it to your clipboard:
<div class="mdx-iconsearch" data-mdx-component="iconsearch">
<input class="md-input md-input--stretch mdx-iconsearch__input" placeholder="Search icon" data-mdx-component="iconsearch-query" value="material circle" />
<div class="mdx-iconsearch-result" data-mdx-component="iconsearch-result" data-mdx-mode="file">
<div class="mdx-iconsearch-result__meta"></div>
<ol class="mdx-iconsearch-result__list"></ol>
</div>
</div>
Some popular choices:
- :material-plus-circle: - `material/plus-circle`
- :material-circle-medium: - `material/circle-medium`
- :material-record-circle: - `material/record-circle`
- :material-arrow-right-circle: - `material/arrow-right-circle`
- :material-arrow-right-circle-outline: - `material/arrow-right-circle-outline`
- :material-chevron-right-circle: - `material/chevron-right-circle`
- :material-star-four-points-circle: - `material/star-four-points-circle`
- :material-plus-circle-outline: - `material/plus-circle-outline`
[Annotation icons support]: https://github.com/squidfunk/mkdocs-material/releases/tag/9.2.0
[custom icon]: ../setup/changing-the-logo-and-icons.md#additional-icons
[icon search]: icons-emojis.md#search
## Usage
### Using annotations
[:octicons-tag-24: 9.2.0b0][Annotation support] ·
[:octicons-tag-24: 9.2.0][Annotation support] ·
:octicons-beaker-24: Experimental
Annotations consist of two parts: a marker, which can be placed anywhere in
@ -64,7 +104,7 @@ Note that the `annotate` class must only be added to the outermost block. All
nested elements can use the same list to define annotations, except when
annotations are nested themselves.
[Annotation support]: https://github.com/squidfunk/mkdocs-material/releases/tag/9.2.0b0
[Annotation support]: https://github.com/squidfunk/mkdocs-material/releases/tag/9.2.0
#### in annotations

View File

@ -136,6 +136,21 @@ but it's always possible to use the [Markdown in HTML] extension with literal
</figure>
</div>
Another option is to use [Markdown captions](https://github.com/Evidlo/markdown_captions) extension
which converts images with alt text to \<figure\> with \<figcaption\>.
``` html title="Image with caption"
![Image caption](https://dummyimage.com/600x400/){ width="300" }
```
<div class="result">
<figure>
<img src="https://dummyimage.com/600x400/f5f5f5/aaaaaa?text=%20Image%20" width="300" />
<figcaption>Image caption</figcaption>
</figure>
</div>
### Image lazy-loading
Modern browsers provide [native support for lazy-loading images][lazy-loading]

View File

@ -121,7 +121,7 @@ description: Nullam urna elit, malesuada eget finibus ut, ac tortor. # (1)!
### Setting the page `icon`
[:octicons-tag-24: 9.2.0b0][Page icon support] ·
[:octicons-tag-24: 9.2.0][Page icon support] ·
:octicons-beaker-24: Experimental
An icon can be assigned to each page, which is then rendered as part of the
@ -149,14 +149,14 @@ icon: material/emoticon-happy # (1)!
</div>
</div>
[Page icon support]: https://github.com/squidfunk/mkdocs-material/releases/tag/9.2.0b0
[Page icon support]: https://github.com/squidfunk/mkdocs-material/releases/tag/9.2.0
[Insiders]: ../insiders/index.md
[icon search]: icons-emojis.md#search
[navigation tabs]: ../setup/setting-up-navigation.md#navigation-tabs
### Setting the page `status`
[:octicons-tag-24: 9.2.0b0][Page status support] ·
[:octicons-tag-24: 9.2.0][Page status support] ·
:octicons-beaker-24: Experimental
A status can be assigned to each page, which is then displayed as part of the
@ -197,7 +197,7 @@ The following status identifiers are currently supported:
- :material-alert-decagram: `new`
- :material-trash-can: `deprecated`
[Page status support]: https://github.com/squidfunk/mkdocs-material/releases/tag/9.2.0b0
[Page status support]: https://github.com/squidfunk/mkdocs-material/releases/tag/9.2.0
### Setting the page `subtitle`

View File

@ -544,6 +544,21 @@
"markdownDescription": "https://facelessuser.github.io/pymdown-extensions/extensions/snippets/#options",
"type": "boolean",
"default": false
},
"url_max_size": {
"markdownDescription": "https://facelessuser.github.io/pymdown-extensions/extensions/snippets/#options",
"type": "integer",
"default": 33554432
},
"url_timeout": {
"markdownDescription": "https://facelessuser.github.io/pymdown-extensions/extensions/snippets/#options",
"type": "number",
"default": 10.0
},
"url_request_headers": {
"markdownDescription": "https://facelessuser.github.io/pymdown-extensions/extensions/snippets/#options",
"type": "object",
"default": {}
}
},
"additionalProperties": false

View File

@ -132,6 +132,22 @@
}
]
},
"annotate": {
"title": "Custom selectors for annotations",
"markdownDescription": "https://squidfunk.github.io/mkdocs-material/reference/code-blocks/#custom-selectors",
"type": "object",
"patternProperties": {
".*": {
"title": "Custom selector",
"markdownDescription": "https://squidfunk.github.io/mkdocs-material/reference/code-blocks/#custom-selectors",
"type": "array",
"items": {
"type": "string",
"pattern": "^\\.\\w+"
}
}
}
},
"consent": {
"title": "Cookie consent",
"markdownDescription": "https://squidfunk.github.io/mkdocs-material/setup/ensuring-data-privacy/#cookie-consent",

View File

@ -33,6 +33,12 @@
"type": "boolean",
"default": false
},
"post_dir": {
"title": "Blog posts directory",
"markdownDescription": "https://squidfunk.github.io/mkdocs-material/setup/setting-up-a-blog/#+blog.post_dir",
"type": "string",
"default": "\"{blog}/posts\""
},
"post_date_format": {
"title": "Format string for post dates",
"markdownDescription": "https://squidfunk.github.io/mkdocs-material/setup/setting-up-a-blog/#+blog.post_date_format",
@ -329,7 +335,7 @@
"title": "Authors file",
"markdownDescription": "https://squidfunk.github.io/mkdocs-material/setup/setting-up-a-blog/#+blog.authors_file",
"type": "string",
"default": ".authors.yml"
"default": "\"{blog}/.authors.yml\""
},
"draft": {
"title": "Render posts marked as drafts",

View File

@ -629,6 +629,13 @@
"content.code.copy"
]
},
{
"title": "Code selection button",
"markdownDescription": "https://squidfunk.github.io/mkdocs-material/reference/code-blocks/#code-selection-button",
"enum": [
"content.code.select"
]
},
{
"title": "Linked content tabs",
"markdownDescription": "https://squidfunk.github.io/mkdocs-material/reference/content-tabs/#linked-content-tabs",

View File

@ -72,6 +72,41 @@ theme:
[Favicon support]: https://github.com/squidfunk/mkdocs-material/releases/tag/0.1.0
[Favicon default]: https://github.com/squidfunk/mkdocs-material/blob/master/material/assets/images/favicon.png
### Site icons
[:octicons-tag-24: 9.2.0][Site icon support]
Most icons you see on your site, such as navigation icons, can also be changed. For example,
to change the navigation arrows in the footer, add the following lines to `mkdocs.yml`:
```yaml
theme:
icon:
previous: fontawesome/solid/angle-left
next: fontawesome/solid/angle-right
```
The following is a complete list of customizable icons used by the theme:
| Icon name | Purpose |
|:-------------|:------------------------------------------------------------------------------|
| `logo` | See [Logo](#logo) |
| `menu` | Open drawer |
| `alternate` | Change language |
| `search` | Search icon |
| `share` | Share search |
| `close` | Reset search, dismiss announcements |
| `top` | Back-to-top button |
| `edit` | Edit current page |
| `view` | View page source |
| `repo` | Repository icon |
| `admonition` | See [Admonition icons](../reference/admonitions.md#admonition-icons) |
| `tag` | See [Tag icons and identifiers](setting-up-tags.md#tag-icons-and-identifiers) |
| `previous` | Previous page in footer, hide search on mobile |
| `next` | Next page in footer |
[Site icon support]: https://github.com/squidfunk/mkdocs-material/releases/tag/9.2.0
## Customization
### Additional icons

View File

@ -19,7 +19,7 @@ __Check out our [blog], which is created with the new [built-in blog plugin]!__
### Built-in blog plugin
[:octicons-tag-24: 9.2.0b0][Blog plugin support] ·
[:octicons-tag-24: 9.2.0][Blog plugin support] ·
:octicons-cpu-24: Plugin ·
:octicons-beaker-24: Experimental
@ -126,7 +126,7 @@ back here later for fine-tuning the output.__
---
[Blog plugin support]: https://github.com/squidfunk/mkdocs-material/releases/tag/9.2.0b0
[Blog plugin support]: https://github.com/squidfunk/mkdocs-material/releases/tag/9.2.0
[Insiders]: ../insiders/index.md
[built-in plugins]: ../insiders/getting-started.md#built-in-plugins
[this is configurable]: #+blog.blog_dir
@ -138,6 +138,22 @@ back here later for fine-tuning the output.__
The following configuration options are available for posts:
[`post_dir`](#+blog.post_dir){ #+blog.post_dir }
: :octicons-milestone-24: Default: `{blog}/posts` This option specifies
the name of the folder in which the blog plugin should look for posts.
The default settings assumes that the folder is called `posts`:
``` yaml
plugins:
- blog:
post_dir: blog/posts
```
The path must be defined relative to [`docs_dir`][docs_dir]. Note that the
`{blog}` placeholder is replaced with the value specified in
[`blog_dir`][this is configurable].
[`post_date_format`](#+blog.post_date_format){ #+blog.post_date_format }
: :octicons-milestone-24: Default: `long` This option specifies the date
@ -689,7 +705,7 @@ The following configuration options are available for index pagination:
pagination_url_format: "{page}"
```
[`pagination_template`](#+blog.pagination_template){ #+blog.pagination_template }
[`pagination_format`](#+blog.pagination_format){ #+blog.pagination_format }
: :octicons-milestone-24: Default: `~2~` This option specifies the format
string that is provided to the [paginate] module, which allows to customize
@ -700,7 +716,7 @@ The following configuration options are available for index pagination:
``` yaml
plugins:
- blog:
pagination_template: "~2~"
pagination_format: "~2~"
```
=== "1 2 3 .. n :material-chevron-right: :material-chevron-double-right:"
@ -708,7 +724,7 @@ The following configuration options are available for index pagination:
``` yaml
plugins:
- blog:
pagination_template: "$link_first $link_previous ~2~ $link_next $link_last"
pagination_format: "$link_first $link_previous ~2~ $link_next $link_last"
```
=== "1 :material-chevron-right:"
@ -716,7 +732,7 @@ The following configuration options are available for index pagination:
``` yaml
plugins:
- blog:
pagination_template: "$link_previous $page $link_next"
pagination_format: "$link_previous $page $link_next"
```
The [paginate] module exposes the following placeholders:
@ -736,6 +752,18 @@ The following configuration options are available for index pagination:
[paginate]: https://pypi.org/project/paginate/
[`pagination_if_single_page`](#+blog.pagination_if_single_page){ #+blog.pagination_if_single_page }
: :octicons-milestone-24: Default: `false` This option specifies whether
the pagination should also be rendered if all posts fit into a single page.
If you wish to always render pagination, add:
``` yaml
plugins:
- blog:
pagination_if_single_page: true
```
[`pagination_keep_content`](#+blog.pagination_keep_content){ #+blog.pagination_keep_content }
: :octicons-milestone-24: Default: `false` This option specifies whether
@ -767,10 +795,10 @@ The following configuration options are available for author info:
[`authors_file`](#+blog.authors_file){ #+blog.authors_file }
: :octicons-milestone-24: Default: `.authors.yml` This option specifies the
name of the file where the authors for your posts resides. The default
settings assumes that the file is called `.authors.yml` (mind the `.` at
the beginning):
: :octicons-milestone-24: Default: `{blog}/.authors.yml` This option
specifies the name of the file where the authors for your posts resides.
The default settings assumes that the file is called `.authors.yml`
(mind the `.` at the beginning):
``` yaml
plugins:
@ -778,8 +806,9 @@ The following configuration options are available for author info:
authors_file: blog/.authors.yml
```
The path must be defined relative to [`docs_dir`][docs_dir].
Also see the section on [adding authors].
The path must be defined relative to [`docs_dir`][docs_dir]. Note that the
`{blog}` placeholder is replaced with the value specified in
[`blog_dir`][this is configurable]. Also see the section on [adding authors].
[adding authors]: #adding-authors
@ -840,8 +869,7 @@ The following configuration options are available for drafts:
### RSS
[:octicons-heart-fill-24:{ .mdx-heart } Sponsors only][Insiders]{ .mdx-insiders } ·
[:octicons-tag-24: insiders-4.23.0][Insiders] ·
[:octicons-tag-24: 9.2.0][Blog plugin support] ·
[:octicons-cpu-24: Plugin][rss]
The [built-in blog plugin] integrates seamlessly with the [RSS plugin][rss],
@ -993,8 +1021,22 @@ categories:
output. [This behavior can be changed], e.g. for rendering drafts when
building deploy previews.
2. You can use `date_updated` to signal when you updated a blog posts. The
date will be rendered as part of the sidebar.
2. If you wish to provide multiple dates, you can use the following syntax,
allowing you to define a date when you last updated the blog post +
further custom dates you can add to the template:
``` yaml
---
date:
created: 2022-01-31
updated: 2022-02-02
---
# Hello world!
```
Note that the creation date __must__ be set under `date.created`, as each
blog post must have a creation date set.
When you spin up the [live preview server], you should be greeted by your first
post! You'll also realize, that [archive] and [category] indexes have been
@ -1359,14 +1401,10 @@ which means you can override all templates used for the blog by using
The following templates are added by the [built-in blog plugin]:
- [`blog.html`][blog.html] Template for blog index
- [`blog.html`][blog.html] Template for blog, archive and category index
- [`blog-post.html`][blog-post.html] Template for blog post
- [`blog-archive.html`][blog-archive.html] Template for blog archive index
- [`blog-category.html`][blog-category.html] Template for blog category index
[theme extension]: ../customization.md#extending-the-theme
[blog.html]: https://github.com/squidfunk/mkdocs-material-insiders/blob/master/src/blog.html
[blog-post.html]: https://github.com/squidfunk/mkdocs-material-insiders/blob/master/src/blog-post.html
[blog-archive.html]: https://github.com/squidfunk/mkdocs-material-insiders/blob/master/src/blog-archive.html
[blog-category.html]: https://github.com/squidfunk/mkdocs-material-insiders/blob/master/src/blog-category.html
[blog-post.html]: https://github.com/squidfunk/mkdocs-material-insiders/blob/master/src/blog-post.htmlhtml

View File

@ -223,7 +223,7 @@ theme:
### Navigation pruning
[:octicons-tag-24: 9.2.0b0][Navigation pruning support] ·
[:octicons-tag-24: 9.2.0][Navigation pruning support] ·
:octicons-beaker-24: Experimental
When pruning is enabled, only the visible navigation items are included in the
@ -245,7 +245,7 @@ This feature flag is especially useful for documentation sites with 100+ or even
Navigation pruning will replace all expandable sections with links to the first
page in that section (or the section index page).
[Navigation pruning support]: https://github.com/squidfunk/mkdocs-material/releases/tag/9.2.0b0
[Navigation pruning support]: https://github.com/squidfunk/mkdocs-material/releases/tag/9.2.0
[navigation.expand]: #navigation-expansion
### Section index pages

View File

@ -16,27 +16,21 @@ MkDocs natively integrates with [Google Analytics] and offers a customizable
[:octicons-tag-24: 7.1.8][Google Analytics support] ·
:octicons-milestone-24: Default: _none_
Material for MkDocs integrates with both, Google Analytics 4 and the now phasing
out Universal Analytics. Depending on the given property prefix, add the
Material for MkDocs integrates natively with Google Analytics 4[^1]. If you
already set up Google Analytics and have a property, enable it by adding the
following lines to `mkdocs.yml`:
=== ":material-google-analytics: Google Analytics 4"
[^1]:
Prior to Material for MkDocs 9.2.0, Universal Analytics was supported as
well. However, since Universal Analytics has been sunset, this integration
was removed in 9.2.0.
``` yaml
extra:
analytics:
provider: google
property: G-XXXXXXXXXX
```
=== ":material-google-analytics: Universal Analytics"
``` yaml
extra:
analytics:
provider: google
property: UA-XXXXXXXX-X
```
``` yaml
extra:
analytics:
provider: google
property: G-XXXXXXXXXX
```
[Google Analytics support]: https://github.com/squidfunk/mkdocs-material/releases/tag/7.1.8
@ -46,21 +40,11 @@ following lines to `mkdocs.yml`:
understand how people use your documentation and what they expect to find.
In order to enable site search tracking, the following steps are required:
=== ":material-google-analytics: Google Analytics 4"
1. Go to your Google Analytics __admin settings__
2. Select the property for the respective tracking code
3. Select the __data streams__ tab and click the corresponding URL
4. Click the gear icon within the __enhanced measurement__ section
5. Ensure that __site search__ is enabled
=== ":material-google-analytics: Universal Analytics"
1. Go to your Google Analytics __admin settings__
2. Select the property for the respective tracking code
3. Go to the __view settings__ tab
4. Scroll down and enable __site search settings__
5. Set the __query parameter__ to `q`
1. Go to your Google Analytics __admin settings__
2. Select the property for the respective tracking code
3. Select the __data streams__ tab and click the corresponding URL
4. Click the gear icon within the __enhanced measurement__ section
5. Ensure that __site search__ is enabled
[site search]: setting-up-site-search.md
@ -102,9 +86,9 @@ extra:
Both properties, `title` and `ratings`, are required. Note that it's allowed to
define more than two ratings, e.g. to implement a 1-5 star rating. Since the
feedback widget sends data to a third-party service, it is, of course, natively
integrated with the [cookie consent] feature[^1].
integrated with the [cookie consent] feature[^2].
[^1]:
[^2]:
If the user doesn't accept the `analytics` cookie, the feedback widget is
not shown.
@ -114,46 +98,35 @@ integrated with the [cookie consent] feature[^1].
[Google Analytics] that will quickly show you the worst- and best-rated
pages of your project documentation.
=== ":material-google-analytics: Google Analytics 4"
1. Go to your Google Analytics __dashboard__
1. Go to your Google Analytics __dashboard__
2. Go to the __configure__ page on the left hand menu, then select
__custom definitions__
2. Go to the __configure__ page on the left hand menu, then select
__custom definitions__
3. Click the __custom metrics__ tab and then __create custom metrics__,
enter the following values:
3. Click the __custom metrics__ tab and then __create custom metrics__,
enter the following values:
* Metric name: Page helpful
* Description: Was this page helpful?
* Event parameter: `data`
* Unit of measurement: Standard
* Metric name: Page helpful
* Description: Was this page helpful?
* Event parameter: `data`
* Unit of measurement: Standard
4. Go to the __explore__ page on the left hand menu, create a new
__blank exploration__
4. Go to the __explore__ page on the left hand menu, create a new
__blank exploration__
5. Configure the report as follows:
5. Configure the report as follows:
* Dimensions: Add `Event name` and `Page location`
* Metrics: Add `Event count` and `Page helpful`
(the custom metric created in step 3)
* Rows: `Page location`
* Values: Drag in both `Event count` and `Page helpful`
* Filters: Add a new filter for
`Event name / exactly matches / feedback`
* Dimensions: Add `Event name` and `Page location`
* Metrics: Add `Event count` and `Page helpful`
(the custom metric created in step 3)
* Rows: `Page location`
* Values: Drag in both `Event count` and `Page helpful`
* Filters: Add a new filter for
`Event name / exactly matches / feedback`
!!! warning "Delay in data availability"
!!! warning "Delay in data availability"
The report may take 24 hours or longer to begin displaying data
=== ":material-google-analytics: Universal Analytics"
1. Go to your Google Analytics __dashboard__
2. Open the __customization__ panel on the left and go to __custom reports__
3. Create a __new custom report__ and set a custom __title__ and __name__
4. Add `Avg. Value` and `Total Events` to __metric group__
5. Add `Event Label` to __dimension drilldown__
6. Add `Event Category` to __filters__ and filter for the value __feedback__
The report may take 24 hours or longer to begin displaying data
Now, after you've saved the report and collected some feedback ratings,
you'll have a list of all pages with the total number of ratings, and an
@ -185,9 +158,9 @@ The following properties are available for each rating:
: :octicons-milestone-24: Default: _none_ · :octicons-alert-24: __Required__
The value of this property is sent as a data value with the custom event
that is transmitted to Google Analytics[^2] (or any custom integration).
that is transmitted to Google Analytics[^3] (or any custom integration).
[^2]:
[^3]:
Note that for Google Analytics, the data value must be an integer.
[`note`](#+analytics.feedback.ratings.note){ #+analytics.feedback.ratings.note }

View File

@ -191,7 +191,7 @@ The following configuration options are supported:
#### Chinese language support
[:octicons-tag-24: 9.2.0b0][Chinese language support] ·
[:octicons-tag-24: 9.2.0][Chinese language support] ·
:octicons-beaker-24: Experimental
In order to add support for Chinese languages to the [built-in search plugin],
@ -236,7 +236,7 @@ The following configuration options are available:
User dictionaries can be used for tuning the segmenter to preserve
technical terms.
[Chinese language support]: https://github.com/squidfunk/mkdocs-material/releases/tag/9.2.0b0
[Chinese language support]: https://github.com/squidfunk/mkdocs-material/releases/tag/9.2.0
[chinese search]: ../blog/posts/chinese-search-support.md
[jieba]: https://pypi.org/project/jieba/
[built-in search plugin]: #built-in-search-plugin

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -0,0 +1,19 @@
# 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.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -27,8 +27,12 @@
{% 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.21">
<meta name="generator" content="mkdocs-{{ mkdocs_version }}, mkdocs-material-9.2.0">
{% endblock %}
{% block htmltitle %}
{% if page.meta and page.meta.title %}
@ -40,18 +44,22 @@
{% endif %}
{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.c439c5a1.min.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.0e669242.min.css' | url }}">
{% if config.theme.palette %}
{% set palette = config.theme.palette %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.ecc896b0.min.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.85d0ee34.min.css' | url }}">
{% endif %}
{% include "partials/icons.html" %}
{% endblock %}
{% block libs %}{% endblock %}
{% block libs %}
{% for script in config.extra.polyfills %}
{{ script | script_tag }}
{% endfor %}
{% endblock %}
{% block fonts %}
{% if config.theme.font != false %}
{% set text = config.theme.font.text | d("Roboto", true) %}
{% set code = config.theme.font.code | d("Roboto Mono", true) %}
{% set text = config.theme.font.get("text", "Roboto") %}
{% set code = config.theme.font.get("code", "Roboto Mono") %}
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family={{
text | replace(' ', '+') + ':300,300i,400,400i,700,700i%7C' +
@ -108,7 +116,8 @@
<div class="md-banner__inner md-grid md-typeset">
{% if "announce.dismiss" in features %}
<button class="md-banner__button md-icon" aria-label="{{ lang.t('announce.dismiss') }}">
{% include ".icons/material/close.svg" %}
{% set icon = config.theme.icon.close or "material/close" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</button>
{% endif %}
{% block announce %}{% endblock %}
@ -184,7 +193,8 @@
</div>
{% if "navigation.top" in features %}
<button type="button" class="md-top md-icon" data-md-component="top" hidden>
{% include ".icons/material/arrow-up.svg" %}
{% set icon = config.theme.icon.top or "material/arrow-up" %}
{% include ".icons/" ~ icon ~ ".svg" %}
{{ lang.t("top") }}
</button>
{% endif %}
@ -212,7 +222,7 @@
"base": base_url,
"features": features,
"translations": {},
"search": "assets/javascripts/workers/search.74e28a9f.min.js" | url
"search": "assets/javascripts/workers/search.dfff1995.min.js" | url
} -%}
{%- if config.extra.version -%}
{%- set _ = app.update({ "version": config.extra.version }) -%}
@ -240,13 +250,9 @@
</script>
{% endblock %}
{% block scripts %}
<script src="{{ 'assets/javascripts/bundle.220ee61c.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.78eede0e.min.js' | url }}"></script>
{% for script in config.extra_javascript %}
{% if script.path %}
{{ script | script_tag }}
{% else %}
<script src="{{ script | url }}"></script>
{% endif %}
{{ script | script_tag }}
{% endfor %}
{% endblock %}
</body>

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

@ -0,0 +1,108 @@
{#-
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.config.date.created }}" class="md-ellipsis">
{{- page.config.date.created | date -}}
</time>
</div>
</li>
{% if page.config.date.updated %}
<li class="md-nav__item">
<div class="md-nav__link">
{% include ".icons/material/calendar-clock.svg" %}
<time datetime="{{ page.config.date.updated }}" class="md-ellipsis">
{{- page.config.date.updated | date -}}
</time>
</div>
</li>
{% endif %}
{% 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.config.readtime %}
{% set time = page.config.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 %}

21
material/blog.html Normal file
View File

@ -0,0 +1,21 @@
{#-
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 %}
{% if pagination %}
{% block pagination %}
{% include "partials/pagination.html" %}
{% endblock %}
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -38,8 +38,8 @@ font:
# Fixed-width font for code listings
code: Roboto Mono
# From Material 5.x on, icons are inlined into the HTML and CSS as SVGs. Some
# icons that are part of the HTML can be configured and replaced
# From Material 5.x on, icons are inlined into the HTML and CSS as SVGs.
# Icons that are part of the HTML can be configured and replaced
icon:
# Favicon to be rendered

View File

@ -0,0 +1,22 @@
{#-
This file was automatically generated - do not edit
-#}
<div class="md-header__option">
<div class="md-select">
{% set icon = config.theme.icon.alternate or "material/translate" %}
<button class="md-header__button md-icon" aria-label="{{ lang.t('select.language') }}">
{% include ".icons/" ~ icon ~ ".svg" %}
</button>
<div class="md-select__inner">
<ul class="md-select__list">
{% for alt in config.extra.alternate %}
<li class="md-select__item">
<a href="{{ alt.link | url }}" hreflang="{{ alt.lang }}" class="md-select__link">
{{ alt.name }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>

View File

@ -1,7 +1,7 @@
{#-
This file was automatically generated - do not edit
-#}
{% if "material/tags" in config.plugins %}
{% if "material/tags" in config.plugins and tags %}
{% include "partials/tags.html" %}
{% endif %}
{% include "partials/actions.html" %}

View File

@ -12,7 +12,8 @@
{% set direction = lang.t("footer.previous") %}
<a href="{{ page.previous_page.url | url }}" class="md-footer__link md-footer__link--prev" aria-label="{{ direction }}: {{ page.previous_page.title | e }}" rel="prev">
<div class="md-footer__button md-icon">
{% include ".icons/material/arrow-left.svg" %}
{% set icon = config.theme.icon.previous or "material/arrow-left" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</div>
<div class="md-footer__title">
<span class="md-footer__direction">
@ -36,7 +37,8 @@
</div>
</div>
<div class="md-footer__button md-icon">
{% include ".icons/material/arrow-right.svg" %}
{% set icon = config.theme.icon.next or "material/arrow-right" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</div>
</a>
{% endif %}

View File

@ -13,7 +13,8 @@
{% include "partials/logo.html" %}
</a>
<label class="md-header__button md-icon" for="__drawer">
{% include ".icons/material/menu" ~ ".svg" %}
{% set icon = config.theme.icon.menu or "material/menu" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</label>
<div class="md-header__title" data-md-component="header-title">
<div class="md-header__ellipsis">
@ -35,45 +36,16 @@
</div>
{% if config.theme.palette %}
{% if not config.theme.palette is mapping %}
<form class="md-header__option" data-md-component="palette">
{% for option in config.theme.palette %}
{% set scheme = option.scheme | d("default", true) %}
{% set primary = option.primary | d("indigo", true) %}
{% set accent = option.accent | d("indigo", true) %}
<input class="md-option" data-md-color-media="{{ option.media }}" data-md-color-scheme="{{ scheme | replace(' ', '-') }}" data-md-color-primary="{{ primary | replace(' ', '-') }}" data-md-color-accent="{{ accent | replace(' ', '-') }}" {% if option.toggle %} aria-label="{{ option.toggle.name }}" {% else %} aria-hidden="true" {% endif %} type="radio" name="__palette" id="__palette_{{ loop.index }}">
{% if option.toggle %}
<label class="md-header__button md-icon" title="{{ option.toggle.name }}" for="__palette_{{ loop.index0 or loop.length }}" hidden>
{% include ".icons/" ~ option.toggle.icon ~ ".svg" %}
</label>
{% endif %}
{% endfor %}
</form>
{% include "partials/palette.html" %}
{% endif %}
{% endif %}
{% if config.extra.alternate %}
<div class="md-header__option">
<div class="md-select">
{% set icon = config.theme.icon.alternate or "material/translate" %}
<button class="md-header__button md-icon" aria-label="{{ lang.t('select.language') }}">
{% include ".icons/" ~ icon ~ ".svg" %}
</button>
<div class="md-select__inner">
<ul class="md-select__list">
{% for alt in config.extra.alternate %}
<li class="md-select__item">
<a href="{{ alt.link | url }}" hreflang="{{ alt.lang }}" class="md-select__link">
{{ alt.name }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% include "partials/alternate.html" %}
{% endif %}
{% if "material/search" in config.plugins %}
<label class="md-header__button md-icon" for="__search">
{% include ".icons/material/magnify.svg" %}
{% set icon = config.theme.icon.search or "material/magnify" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</label>
{% include "partials/search.html" %}
{% endif %}

View File

@ -15,6 +15,18 @@
{% set _ = style.append("}\x3c/style\x3e") %}
{{ style | join }}
{% endif %}
{% if config.theme.icon.annotation %}
{% set style = ["\x3cstyle\x3e:root{"] %}
{% import ".icons/" ~ config.theme.icon.annotation ~ ".svg" as icon %}
{% set _ = style.append(
"--md-annotation-icon:" ~
"url('data:image/svg+xml;charset=utf-8," ~
icon | replace("\n", "") ~
"');"
) %}
{% set _ = style.append("}\x3c/style\x3e") %}
{{ style | join }}
{% endif %}
{% if config.theme.icon.tag %}
{% set style = ["\x3cstyle\x3e"] %}
{% for type, icon in config.theme.icon.tag.items() %}

View File

@ -4,8 +4,4 @@
{% if config.extra.analytics %}
{% set property = config.extra.analytics.property | d("", true) %}
{% endif %}
{% if property.startswith("G-") %}
<script id="__analytics">function __md_analytics(){function n(){dataLayer.push(arguments)}window.dataLayer=window.dataLayer||[],n("js",new Date),n("config","{{ property }}"),document.addEventListener("DOMContentLoaded",function(){document.forms.search&&document.forms.search.query.addEventListener("blur",function(){this.value&&n("event","search",{search_term:this.value})}),document$.subscribe(function(){var a=document.forms.feedback;if(void 0!==a)for(var e of a.querySelectorAll("[type=submit]"))e.addEventListener("click",function(e){e.preventDefault();var t=document.location.pathname,e=this.getAttribute("data-md-value");n("event","feedback",{page:t,data:e}),a.firstElementChild.disabled=!0;e=a.querySelector(".md-feedback__note [data-md-value='"+e+"']");e&&(e.hidden=!1)}),a.hidden=!1}),location$.subscribe(function(e){n("config","{{ property }}",{page_path:e.pathname})})});var e=document.createElement("script");e.async=!0,e.src="https://www.googletagmanager.com/gtag/js?id={{ property }}",document.getElementById("__analytics").insertAdjacentElement("afterEnd",e)}</script>
{% elif property.startswith("UA-") %}
<script id="__analytics">function __md_analytics(){window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)},ga.l=+new Date,ga("create","{{ property }}","auto"),ga("set","anonymizeIp",!0),ga("send","pageview"),document.addEventListener("DOMContentLoaded",function(){document.forms.search&&document.forms.search.query.addEventListener("blur",function(){var e;this.value&&(e=document.location.pathname,ga("send","pageview",e+"?q="+this.value))}),document$.subscribe(function(){var a=document.forms.feedback;if(void 0!==a)for(var e of a.querySelectorAll("[type=submit]"))e.addEventListener("click",function(e){e.preventDefault();var t=document.location.pathname,e=this.getAttribute("data-md-value");ga("send","event","feedback","click",t,e),a.firstElementChild.disabled=!0;e=a.querySelector(".md-feedback__note [data-md-value='"+e+"']");e&&(e.hidden=!1)}),a.hidden=!1}),location$.subscribe(function(e){ga("send","pageview",e.pathname)})});var e=document.createElement("script");e.async=!0,e.src="https://www.google-analytics.com/analytics.js",document.getElementById("__analytics").insertAdjacentElement("afterEnd",e)}</script>
{% endif %}
<script id="__analytics">function __md_analytics(){function n(){dataLayer.push(arguments)}window.dataLayer=window.dataLayer||[],n("js",new Date),n("config","{{ property }}"),document.addEventListener("DOMContentLoaded",function(){document.forms.search&&document.forms.search.query.addEventListener("blur",function(){this.value&&n("event","search",{search_term:this.value})}),document$.subscribe(function(){var a=document.forms.feedback;if(void 0!==a)for(var e of a.querySelectorAll("[type=submit]"))e.addEventListener("click",function(e){e.preventDefault();var t=document.location.pathname,e=this.getAttribute("data-md-value");n("event","feedback",{page:t,data:e}),a.firstElementChild.disabled=!0;e=a.querySelector(".md-feedback__note [data-md-value='"+e+"']");e&&(e.hidden=!1)}),a.hidden=!1}),location$.subscribe(function(e){n("config","{{ property }}",{page_path:e.pathname})})});var e=document.createElement("script");e.async=!0,e.src="https://www.googletagmanager.com/gtag/js?id={{ property }}",document.getElementById("__analytics").insertAdjacentElement("afterEnd",e)}</script>

View File

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

View File

@ -28,7 +28,7 @@
"meta.source": "Source",
"nav": "Navigation",
"readtime.one": "1 min de lecture",
"readtime.other": "# min lues",
"readtime.other": "# min de lecture",
"rss.created": "Flux RSS",
"rss.updated": "Flux RSS du contenu mis à jour",
"search": "Recherche",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,16 @@
{#-
This file was automatically generated - do not edit
-#}
<form class="md-header__option" data-md-component="palette">
{% for option in config.theme.palette %}
{% set scheme = option.scheme | d("default", true) %}
{% set primary = option.primary | d("indigo", true) %}
{% set accent = option.accent | d("indigo", true) %}
<input class="md-option" data-md-color-media="{{ option.media }}" data-md-color-scheme="{{ scheme | replace(' ', '-') }}" data-md-color-primary="{{ primary | replace(' ', '-') }}" data-md-color-accent="{{ accent | replace(' ', '-') }}" {% if option.toggle %} aria-label="{{ option.toggle.name }}" {% else %} aria-hidden="true" {% endif %} type="radio" name="__palette" id="__palette_{{ loop.index }}">
{% if option.toggle %}
<label class="md-header__button md-icon" title="{{ option.toggle.name }}" for="__palette_{{ loop.index0 or loop.length }}" hidden>
{% include ".icons/" ~ option.toggle.icon ~ ".svg" %}
</label>
{% endif %}
{% endfor %}
</form>

View File

@ -0,0 +1,60 @@
{#-
This file was automatically generated - do not edit
-#}
<article class="md-post md-post--excerpt">
<header class="md-post__header">
{% if post.authors %}
<nav class="md-post__authors md-typeset">
{% for author in post.authors %}
<span class="md-author">
<img src="{{ author.avatar }}" alt="{{ author.name }}">
</span>
{% endfor %}
</nav>
{% endif %}
<div class="md-post__meta md-meta">
<ul class="md-meta__list">
<li class="md-meta__item">
<time datetime="{{ post.config.date.created }}">
{{- post.config.date.created | date -}}
</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.config.readtime %}
{% set time = post.config.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.config.draft %}
<span class="md-draft">
{{ lang.t("blog.draft") }}
</span>
{% endif %}
</div>
</header>
<div class="md-post__content md-typeset">
{{ post.content }}
<nav class="md-post__action">
<a href="{{ post.url | url }}">
{{ lang.t("blog.continue") }}
</a>
</nav>
</div>
</article>

View File

@ -7,17 +7,21 @@
<form class="md-search__form" name="search">
<input type="text" class="md-search__input" name="query" aria-label="{{ lang.t('search.placeholder') }}" placeholder="{{ lang.t('search.placeholder') }}" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" data-md-component="search-query" required>
<label class="md-search__icon md-icon" for="__search">
{% include ".icons/material/magnify.svg" %}
{% include ".icons/material/arrow-left.svg" %}
{% set icon = config.theme.icon.search or "material/magnify" %}
{% include ".icons/" ~ icon ~ ".svg" %}
{% set icon = config.theme.icon.previous or "material/arrow-left" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</label>
<nav class="md-search__options" aria-label="{{ lang.t('search') }}">
{% if "search.share" in features %}
<a href="javascript:void(0)" class="md-search__icon md-icon" title="{{ lang.t('search.share') }}" aria-label="{{ lang.t('search.share') }}" data-clipboard data-clipboard-text="" data-md-component="search-share" tabindex="-1">
{% include ".icons/material/share-variant.svg" %}
{% set icon = config.theme.icon.share or "material/share-variant" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</a>
{% endif %}
<button type="reset" class="md-search__icon md-icon" title="{{ lang.t('search.reset') }}" aria-label="{{ lang.t('search.reset') }}" tabindex="-1">
{% include ".icons/material/close.svg" %}
{% set icon = config.theme.icon.close or "material/close" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</button>
</nav>
{% if "search.suggest" in features %}

View File

@ -1,28 +1,35 @@
{#-
This file was automatically generated - do not edit
-#}
{% if not class %}
{% set class = "md-tabs__link" %}
{% if nav_item.active %}
{% set class = class ~ " md-tabs__link--active" %}
{% 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__item" %}
{% if ref.active %}
{% set class = class ~ " md-tabs__item--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="{{ class }}">
<a href="{{ first.url | url }}" class="md-tabs__link">
{{ render_content(first, ref) }}
</a>
</li>
{% endif %}
{% else %}
<li class="md-tabs__item">
<a href="{{ nav_item.url | url }}" class="{{ class }}">
{{ title }}
<li class="{{ class }}">
<a href="{{ nav_item.url | url }}" class="md-tabs__link">
{{ render_content(nav_item) }}
</a>
</li>
{% endif %}
{% else %}
<li class="md-tabs__item">
<a href="{{ nav_item.url | url }}" class="{{ class }}">
{{ nav_item.title }}
</a>
</li>
{% endif %}
{% endmacro %}

View File

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

View File

@ -4,24 +4,23 @@
{% if page.meta and page.meta.hide %}
{% set hidden = "hidden" if "tags" in page.meta.hide %}
{% endif %}
{% if tags %}
<nav class="md-tags" {{ hidden }}>
{% for tag in tags %}
{% if config.extra.tags %}
{% set icon = " md-tag-icon" %}
{% if tag.type %}
{% set icon = icon ~ " md-tag--" ~ tag.type %}
{% endif %}
<nav class="md-tags" {{ hidden }}>
{% for tag in tags %}
{% set icon = "" %}
{% if config.extra.tags %}
{% set icon = " md-tag-icon" %}
{% if tag.type %}
{% set icon = icon ~ " md-tag--" ~ tag.type %}
{% endif %}
{% if tag.url %}
<a href="{{ tag.url | url }}" class="md-tag{{ icon }}">
{{- tag.name -}}
</a>
{% else %}
<span class="md-tag{{ icon }}">
{{- tag.name -}}
</span>
{% endif %}
{% endfor %}
</nav>
{% endif %}
{% endif %}
{% if tag.url %}
<a href="{{ tag.url | url }}" class="md-tag{{ icon }}">
{{- tag.name -}}
</a>
{% else %}
<span class="md-tag{{ icon }}">
{{- tag.name -}}
</span>
{% endif %}
{% endfor %}
</nav>

View File

@ -0,0 +1,19 @@
# 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.

View File

@ -0,0 +1,19 @@
# 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.

View File

@ -0,0 +1,38 @@
# 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 DictOfItems, SubConfig, Type
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Author
class Author(Config):
name = Type(str)
description = Type(str)
avatar = Type(str)
# -----------------------------------------------------------------------------
# Authors
class Authors(Config):
authors = DictOfItems(SubConfig(Author), default = {})

View File

@ -0,0 +1,88 @@
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
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
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Blog plugin configuration
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_dir = Type(str, default = "{blog}/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_format = Type(str, default = "~2~")
pagination_if_single_page = Type(bool, default = False)
pagination_keep_content = Type(bool, default = False)
# 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)
# Deprecated options
pagination_template = Deprecated(moved_to = "pagination_format")

View File

@ -0,0 +1,813 @@
# 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 posixpath
import readtime
import yaml
from babel.dates import format_date
from datetime import datetime
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin, event_priority
from mkdocs.structure import StructureItem
from mkdocs.structure.files import File, Files, InclusionLevel
from mkdocs.structure.nav import Navigation, Section
from mkdocs.structure.pages import Page
from mkdocs.utils import get_relative_url
from paginate import Page as Pagination
from shutil import rmtree
from tempfile import mkdtemp
from yaml import SafeLoader
from .author import Author, Authors
from .config import BlogConfig
from .structure import Archive, Category, Excerpt, Post, View
from .templates import url_filter
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Blog plugin
class BlogPlugin(BasePlugin[BlogConfig]):
supports_multiple_instances = True
# Initialize plugin
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize incremental builds
self.is_serve = False
self.is_dirty = False
# Initialize a temporary directory
self.temp_dir = mkdtemp()
# Determine whether we're serving the site
def on_startup(self, *, command, dirty):
self.is_serve = command == "serve"
self.is_dirty = dirty
# Initialize authors and set defaults
def on_config(self, config):
if not self.config.enabled:
return
# Initialize entrypoint
self.blog: View
# Initialize and resolve authors, if enabled
if self.config.authors:
self.authors = self._resolve_authors(config)
# Initialize table of contents settings
if not isinstance(self.config.archive_toc, bool):
self.config.archive_toc = self.config.blog_toc
if not isinstance(self.config.categories_toc, bool):
self.config.categories_toc = self.config.blog_toc
# By default, drafts are rendered when the documentation is served,
# but not when it is built, for a better authoring experience
if self.is_serve and self.config.draft_on_serve:
self.config.draft = True
# Remove posts before constructing navigation (run later) - allow other
# plugins to alter the list of files and navigation prior to this plugin
@event_priority(-50)
def on_files(self, files, *, config):
if not self.config.enabled:
return
# Resolve path to entrypoint and site directory
root = posixpath.normpath(self.config.blog_dir)
site = config.site_dir
# Compute path to posts directory
path = self.config.post_dir.format(blog = root)
path = posixpath.normpath(path)
# Temporarily remove posts and adjust destination paths for assets
for file in files:
if not file.src_uri.startswith(path):
continue
# We must exclude all files related to posts from here on, so MkDocs
# will not attach the posts to the navigation when auto-populating.
# We add them back in `on_nav`, so MkDocs processes them, unless
# excluded by being tagged as a draft or through other means.
if file.is_documentation_page():
file.inclusion = InclusionLevel.EXCLUDED
# We also need to adjust destination paths for assets to remove the
# purely functional posts directory prefix when building
if file.is_media_file():
file.dest_uri = file.dest_uri.replace(path, root)
file.abs_dest_path = os.path.join(site, file.dest_path)
file.url = file.url.replace(path, root)
# Resolve and load posts and generate indexes (run later) - we resolve all
# posts after the navigation is constructed in order to allow other plugins
# to alter the navigation (e.g. awesome-pages) before we start to add pages
# generated by this plugin. Post URLs must be computed before any Markdown
# processing, so that when linking to and from posts, MkDocs behaves exactly
# the same as with regular documentation pages. We create all pages related
# to posts as part of this plugin, so we control the entire process.
@event_priority(-50)
def on_nav(self, nav, *, config, files):
if not self.config.enabled:
return
# Resolve entrypoint and posts sorted by descending date - if the posts
# directory or entrypoint do not exist, they are automatically created
self.blog = self._resolve(files, config, nav)
self.blog.posts = sorted(
self._resolve_posts(files, config),
key = lambda post: post.config.date.created,
reverse = True
)
# Attach posts to entrypoint without adding them to the navigation, so
# that the entrypoint is considered to be the active page for each post.
# Hack: MkDocs has a bug where pages that are marked to be not in the
# navigation are auto-populated nonetheless - see https://t.ly/7aYnO
self._attach(self.blog, [None, *reversed(self.blog.posts), None])
for post in self.blog.posts:
post.file.inclusion = InclusionLevel.NOT_IN_NAV
# Generate and attach views for archive
if self.config.archive:
views = [*self._generate_archive(config, files)]
self.blog.views.extend(views)
# Attach and link views for archive
title = self._translate(self.config.archive_name, config)
self._attach_to(self.blog.parent, Section(title, views), nav)
# Generate and attach views for categories
if self.config.categories:
views = [*self._generate_categories(config, files)]
self.blog.views.extend(views)
# Attach and link views for categories
title = self._translate(self.config.categories_name, config)
self._attach_to(self.blog.parent, Section(title, views), nav)
# Paginate generated views, if enabled
if self.config.pagination:
for view in [*self._resolve_views(self.blog)]:
for page in self._generate_pages(view, config, files):
view.pages.append(page)
# Prepare post for rendering (run later) - allow other plugins to alter
# the contents or metadata of a post before it is rendered and make sure
# that the post includes a separator, which is essential for rendering
# excerpts that should be included in views
@event_priority(-50)
def on_page_markdown(self, markdown, *, page, config, files):
if not self.config.enabled:
return
# Skip if page is not a post managed by this instance - this plugin has
# support for multiple instances, which is why this check is necessary
if page not in self.blog.posts:
if not self.config.pagination:
return
# We set the contents of the view to its title if pagination should
# not keep the content of the original view on paginaged views
if not self.config.pagination_keep_content:
if page in self._resolve_views(self.blog):
assert isinstance(page, View)
if 0 < page.pages.index(page):
return f"# {page.title}"
# Nothing more to be done for views
return
# Extract and assign authors to post, if enabled
if self.config.authors:
for name in page.config.authors:
if name not in self.authors:
raise PluginError(f"Couldn't find author '{name}'")
# Append to list of authors
page.authors.append(self.authors[name])
# Compute readtime of post, if enabled and not explicitly set
if self.config.post_readtime:
rate = self.config.post_readtime_words_per_minute
# There's a bug in the readtime library which causes it to fail if
# the input string contains emojis - see https://t.ly/qEoHq
if not page.config.readtime:
data = markdown.encode("unicode_escape")
read = readtime.of_markdown(data, rate)
page.config.readtime = read.minutes
# Extract settings for excerpts
separator = self.config.post_excerpt_separator
max_authors = self.config.post_excerpt_max_authors
max_categories = self.config.post_excerpt_max_categories
# Ensure presence of separator and throw, if its absent and required -
# we append the separator to the end of the contents of the post, if it
# is not already present, so we can remove footnotes or other content
# from the excerpt without affecting the content of the excerpt
if separator not in page.markdown:
path = page.file.src_uri
if self.config.post_excerpt == "required":
raise PluginError(
f"Couldn't find '{separator}' separator in '{path}'"
)
else:
page.markdown += f"\n\n{separator}"
# Create excerpt for post and inherit authors and categories - excerpts
# can contain a subset of the authors and categories of the post
page.excerpt = Excerpt(page, config, files)
page.excerpt.authors = page.authors[:max_authors]
page.excerpt.categories = page.categories[:max_categories]
# Register template filters for plugin
def on_env(self, env, *, config, files):
if not self.config.enabled:
return
# Filter for formatting dates related to posts
def date_filter(date: datetime):
return self._format_date_for_post(date, config)
# Register custom template filters
env.filters["date"] = date_filter
env.filters["url"] = url_filter
# Prepare view for rendering (run latest) - views are rendered last, as we
# need to mutate the navigation to account for pagination. The main problem
# is that we need to replace the view in the navigation, because otherwise
# the view would not be considered active.
@event_priority(-100)
def on_page_context(self, context, *, page, config, nav):
if not self.config.enabled:
return
# Skip if page is not a view managed by this instance - this plugin has
# support for multiple instances, which is why this check is necessary
if page not in self._resolve_views(self.blog):
return
# Retrieve parent view or section
assert isinstance(page, View)
main = page.parent
# If this page is a view, and the parent page is a view as well, we got
# a paginated view and need to update the parent view in the navigation.
# Paginated views are always rendered last, which is why we can safely
# mutate the navigation at this point
if isinstance(main, View):
assert isinstance(main.parent, Section)
# Replace view in navigation and rewire view - the current view in
# the navigation becomes the main view, thus the entire chain moves
# one level up. It's essential that the rendering order is linear,
# or else we might end up with a broken navigation.
at = main.parent.children.index(main)
main.parent.children[at] = page
page.parent = main.parent
# Render excerpts and perpare pagination
posts, pagination = self._render(page)
# Render pagination links
def pager(args: object):
return pagination.pager(
format = self.config.pagination_format,
show_if_single_page = self.config.pagination_if_single_page,
**args
)
# Assign posts and pagination to context
context["posts"] = posts
context["pagination"] = pager if pagination else None
# Remove temporary directory on shutdown
def on_shutdown(self):
rmtree(self.temp_dir)
# -------------------------------------------------------------------------
# Check if the given post is excluded
def _is_excluded(self, post: Post):
if self.config.draft:
return False
# If a post was not explicitly marked or unmarked as draft, and the
# date should be taken into account, we automatically mark it as draft
# if the publishing date is in the future. This, of course, is opt-in
# and must be explicitly enabled by the author.
if not isinstance(post.config.draft, bool):
if self.config.draft_if_future_date:
return post.config.date > datetime.now()
# Post might be a draft
return bool(post.config.draft)
# -------------------------------------------------------------------------
# Resolve entrypoint - the entrypoint of the blog hosts all posts, sorted
# by descending date. The entrypoint must always be present, even if there
# are no posts, and is automatically created if it does not exist yet. Note
# that posts might be paginated, but this is configurable by the author.
def _resolve(self, files: Files, config: MkDocsConfig, nav: Navigation):
path = os.path.join(self.config.blog_dir, "index.md")
path = os.path.normpath(path)
# Create entrypoint, if it does not exist
docs = os.path.relpath(config.docs_dir)
file = os.path.join(docs, path)
if not os.path.isfile(file):
self._save_to_file(file, "# Blog\n\n")
# Append entrypoint to files - note that the entrypoint is added to
# the docs directory, so we need to set the temporary flag to false
files.append(self._path_to_file(path, config, temp = False))
# Obtain entrypoint page
file = files.get_file_from_path(path)
page = file.page
# Create entrypoint view and attach to parent
view = View(page.title, file, config)
self._attach(page.parent, [
page.previous_page,
view,
page.next_page
])
# Update entrypoint in navigation
for items in [view.parent.children, nav.pages]:
items[items.index(page)] = view
# Return view
return view
# Resolve post - the caller must make sure that the given file points to an
# actual post (and not a page), or behavior might be unpredictable
def _resolve_post(self, file: File, config: MkDocsConfig):
post = Post(file, config)
# Compute path and create a temporary file for path resolution
path = self._format_path_for_post(post, config)
temp = self._path_to_file(path, config, temp = False)
# Replace post destination file system path and URL
file.dest_uri = temp.dest_uri
file.abs_dest_path = temp.abs_dest_path
file.url = temp.url
# Replace canonical URL and return post
post._set_canonical_url(config.site_url)
return post
# Resolve posts from directory - traverse all documentation pages and filter
# and yield those that are located in the posts directory
def _resolve_posts(self, files: Files, config: MkDocsConfig):
path = self.config.post_dir.format(blog = self.config.blog_dir)
path = os.path.normpath(path)
# Create posts directory, if it does not exist
docs = os.path.relpath(config.docs_dir)
name = os.path.join(docs, path)
if not os.path.isdir(name):
os.makedirs(name, exist_ok = True)
# Filter posts from pages - prior to calling this function, the caller
# should've excluded all posts, so they're not listed in the navigation
inclusion = InclusionLevel.is_excluded
for file in files.documentation_pages(inclusion = inclusion):
if not file.src_path.startswith(path):
continue
# Resolve post - in order to determine whether a post should be
# excluded, we must load it and analyze its metadata. All posts
# marked as drafts are excluded, except for when the author has
# configured drafts to be included in the navigation.
post = self._resolve_post(file, config)
if not self._is_excluded(post):
yield post
# Resolve authors - check if there's an authors file at the configured
# location, and if one was found, load and validate it
def _resolve_authors(self, config: MkDocsConfig):
path = self.config.authors_file.format(blog = self.config.blog_dir)
path = os.path.normpath(path)
# If the authors file does not exist, return an empty dictionary
docs = os.path.relpath(config.docs_dir)
file = os.path.join(docs, path)
if not os.path.isfile(file):
authors: dict[str, Author] = dict()
return authors
# Open file and parse as YAML
with open(file, encoding = "utf-8") as f:
config: Authors = Authors(os.path.abspath(file))
try:
config.load_dict(yaml.load(f, SafeLoader) or {})
# The authors file could not be loaded because of a syntax error,
# which we display to the user with a nice error message
except Exception as e:
raise PluginError(
f"Error reading authors file '{path}' in '{docs}':\n"
f"{e}"
)
# Validate authors and throw if errors occurred
errors, warnings = config.validate()
if not config.authors and warnings:
log.warning(
f"Action required: the format of the authors file changed.\n"
f"All authors must now be located under the 'authors' key.\n"
f"Please adjust '{file}' to match:\n"
f"\n"
f"authors:\n"
f" squidfunk:\n"
f" avatar: https://avatars.githubusercontent.com/u/932156\n"
f" description: Creator\n"
f" name: Martin Donath\n"
f"\n"
)
for _, w in warnings:
log.warning(w)
for _, e in errors:
raise PluginError(
f"Error reading authors file '{path}' in '{docs}':\n"
f"{e}"
)
# Return authors
return config.authors
# Resolve views and pages of the given view that were generated by this
# plugin when building the site and yield them in pre-order
def _resolve_views(self, view: View):
yield view
# Resolve views recursively
for page in view.views:
for next in self._resolve_views(page):
assert isinstance(next, View)
yield next
# Resolve pages
for page in view.pages:
assert isinstance(page, View)
yield page
# -------------------------------------------------------------------------
# Attach a list of pages to each other and to the given parent item without
# explicitly adding them to the navigation, which can be done by the caller
def _attach(self, parent: StructureItem, pages: list[Page]):
for tail, page, head in zip(pages, pages[1:], pages[2:]):
# Link page to parent and siblings
page.parent = parent
page.previous_page = tail
page.next_page = head
# Attach a section to the given parent section, make sure it's pages are
# part of the navigation, and ensure all pages are linked correctly
def _attach_to(self, parent: Section, section: Section, nav: Navigation):
section.parent = parent
# Determine the parent section to attach the section to, which might be
# the top-level navigation, if no parent section was given. Note, that
# it's currently not possible to chose the position of a section, but
# we might add support for this in the future.
items = parent.children if parent else nav.items
items.append(section)
# Find last sibling that is a page, skipping sections, as we need to
# append the given section after all other pages
tail = next(item for item in reversed(items) if isinstance(item, Page))
head = tail.next_page
# Attach section to navigation and pages to each other
nav.pages.extend(section.children)
self._attach(section, [tail, *section.children, head])
# -------------------------------------------------------------------------
# Generate views for archive - analyze posts and generate the necessary
# views, taking the date format provided by the author into account
def _generate_archive(self, config: MkDocsConfig, files: Files):
for post in self.blog.posts:
date = post.config.date.created
# Compute name and path of archive view
name = self._format_date_for_archive(date, config)
path = self._format_path_for_archive(post, config)
# Create view for archive if it doesn't exist
file = files.get_file_from_path(path)
if not file:
file = self._path_to_file(path, config)
files.append(file)
# Create and yield archive view
self._save_to_file(file.abs_src_path, f"# {name}")
yield Archive(name, file, config)
# Assign post to archive
assert isinstance(file.page, Archive)
file.page.posts.append(post)
# Generate views for categories - analyze posts and generate the necessary
# views, taking the allowed categories as set by the author into account
def _generate_categories(self, config: MkDocsConfig, files: Files):
for post in self.blog.posts:
for name in post.config.categories:
path = self._format_path_for_category(name)
# Ensure category is in non-empty allow list
categories = self.config.categories_allowed or [name]
if name not in categories:
docs = os.path.relpath(config.docs_dir)
path = os.path.relpath(post.file.abs_src_path, docs)
raise PluginError(
f"Error reading categories of post '{path}' in "
f"'{docs}': category '{name}' not in allow list"
)
# Create view for category if it doesn't exist
file = files.get_file_from_path(path)
if not file:
file = self._path_to_file(path, config)
files.append(file)
# Create and yield archive view
self._save_to_file(file.abs_src_path, f"# {name}")
yield Category(name, file, config)
# Assign post to category and vice versa
assert isinstance(file.page, Category)
file.page.posts.append(post)
post.categories.append(file.page)
# Generate pages for pagination - analyze view and generate the necessary
# pages, creating a chain of views for simple rendering and replacement
def _generate_pages(self, view: View, config: MkDocsConfig, files: Files):
yield view
# Extract settings for pagination
step = self.config.pagination_per_page
prev = view
# Compute pagination boundaries and create pages
for at in range(step, len(view.posts), step):
path = self._format_path_for_pagination(view.url, 1 + at // step)
file = self._path_to_file(path, config)
# Replace post source file system path and apend to files
file.src_uri = view.file.src_uri
file.abs_src_path = view.file.abs_src_path
files.append(file)
# Create view and attach to previous page
next = View(view.title, file, config)
self._attach(prev, [
view.previous_page,
next,
view.next_page
])
# Assign posts and pages to view
next.posts = view.posts
next.pages = view.pages
# Continue with next page
prev = next
yield next
# -------------------------------------------------------------------------
# Render excerpts and pagination for the given view
def _render(self, view: View):
posts, pagination = view.posts, None
# Create pagination, if enabled
if self.config.pagination:
at = view.pages.index(view)
# Compute pagination boundaries
step = self.config.pagination_per_page
p, q = at * step, at * step + step
# Extract posts in pagination boundaries
posts = view.posts[p:q]
pagination = self._render_pagination(view, (p, q))
# Render excerpts for selected posts
posts = [
self._render_post(post.excerpt, view)
for post in posts
]
# Return posts and pagination
return posts, pagination
# Render excerpt in the context of the given view
def _render_post(self, excerpt: Excerpt, view: View):
excerpt.render(view, self.config.post_excerpt_separator)
# Determine whether to add posts to the table of contents of the view -
# note that those settings can be changed individually for each type of
# view, which is why we need to check the type of view and the table of
# contents setting for that type of view
toc = self.config.blog_toc
if isinstance(view, Archive):
toc = self.config.archive_toc
if isinstance(view, Category):
toc = self.config.categories_toc
# Attach top-level table of contents item to view if it should be added
# and both, the view and excerpt contain table of contents items
if toc and excerpt.toc.items and view.toc.items:
view.toc.items[0].children.append(excerpt.toc.items[0])
# Return excerpt
return excerpt
# Create pagination for the given view and range
def _render_pagination(self, view: View, range: tuple[int, int]):
p, q = range
# Create URL from the given page to another page
def url_maker(n: int):
return get_relative_url(view.pages[n - 1].url, view.url)
# Return pagination
return Pagination(
view.posts, page = q // (q - p),
items_per_page = q - p,
url_maker = url_maker
)
# -------------------------------------------------------------------------
# Format path for post
def _format_path_for_post(self, post: Post, config: MkDocsConfig):
categories = post.config.categories[:self.config.post_url_max_categories]
categories = [self._slugify_category(name) for name in categories]
# Replace placeholders in format string
date = post.config.date.created
path = self.config.post_url_format.format(
categories = "/".join(categories),
date = self._format_date_for_post_url(date, config),
file = post.file.name,
slug = post.config.slug or self._slugify_post(post)
)
# Normalize path and strip slashes at the beginning and end
path = posixpath.normpath(path.strip("/"))
return posixpath.join(self.config.blog_dir, f"{path}.md")
# Format path for archive
def _format_path_for_archive(self, post: Post, config: MkDocsConfig):
date = post.config.date.created
path = self.config.archive_url_format.format(
date = self._format_date_for_archive_url(date, config)
)
# Normalize path and strip slashes at the beginning and end
path = posixpath.normpath(path.strip("/"))
return posixpath.join(self.config.blog_dir, f"{path}.md")
# Format path for category
def _format_path_for_category(self, name: str):
path = self.config.categories_url_format.format(
slug = self._slugify_category(name)
)
# Normalize path and strip slashes at the beginning and end
path = posixpath.normpath(path.strip("/"))
return posixpath.join(self.config.blog_dir, f"{path}.md")
# Format path for pagination
def _format_path_for_pagination(self, base: str, page: int):
path = self.config.pagination_url_format.format(
page = page
)
# Normalize path and strip slashes at the beginning and end
path = posixpath.normpath(path.strip("/"))
return posixpath.join(base, f"{path}.md")
# -------------------------------------------------------------------------
# Format date
def _format_date(self, date: datetime, format: str, config: MkDocsConfig):
locale = config.theme["language"]
return format_date(date, format = format, locale = locale)
# Format date for post
def _format_date_for_post(self, date: datetime, config: MkDocsConfig):
format = self.config.post_date_format
return self._format_date(date, format, config)
# Format date for post URL
def _format_date_for_post_url(self, date: datetime, config: MkDocsConfig):
format = self.config.post_url_date_format
return self._format_date(date, format, config)
# Format date for archive
def _format_date_for_archive(self, date: datetime, config: MkDocsConfig):
format = self.config.archive_date_format
return self._format_date(date, format, config)
# Format date for archive URL
def _format_date_for_archive_url(self, date: datetime, config: MkDocsConfig):
format = self.config.archive_url_date_format
return self._format_date(date, format, config)
# -------------------------------------------------------------------------
# Slugify post title
def _slugify_post(self, post: Post):
separator = self.config.post_slugify_separator
return self.config.post_slugify(post.title, separator)
# Slugify category
def _slugify_category(self, name: str):
separator = self.config.categories_slugify_separator
return self.config.categories_slugify(name, separator)
# -------------------------------------------------------------------------
# Create a file for the given path, which must point to a valid source file,
# either inside the temporary directory or the docs directory
def _path_to_file(self, path: str, config: MkDocsConfig, *, temp = True):
assert path.endswith(".md")
file = File(
path,
config.docs_dir if not temp else self.temp_dir,
config.site_dir,
config.use_directory_urls
)
# Hack: 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 that
# still needs to be adopted by MkDocs, and was introduced by the
# git-revision-date-localized-plugin - see https://bit.ly/3ZUmdBx
if temp:
file.generated_by = "material/blog"
# Return file
return file
# Write the content to the file located at the given path
def _save_to_file(self, path: str, content: str):
os.makedirs(os.path.dirname(path), exist_ok = True)
with open(path, "w") as f:
f.write(content)
# -------------------------------------------------------------------------
# Translate the placeholder referenced by the given key
def _translate(self, key: str, config: MkDocsConfig) -> str:
env = config.theme.get_env()
template = env.get_template(
"partials/language.html", globals = { "config": config }
)
# Translate placeholder
return template.module.t(key)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.blog")

View File

@ -0,0 +1,277 @@
# 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 yaml
from copy import copy
from markdown import Markdown
from material.plugins.blog.author import Author
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import PluginError
from mkdocs.structure.files import File, Files
from mkdocs.structure.nav import Section
from mkdocs.structure.pages import Page, _RelativePathTreeprocessor
from mkdocs.structure.toc import get_toc
from mkdocs.utils.meta import YAML_RE
from re import Match
from typing import Union
from yaml import SafeLoader
from .config import PostConfig
from .markdown import ExcerptTreeprocessor
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Post
class Post(Page):
# Initialize post - posts are never listed in the navigation, which is why
# they will never include a title that was manually set, so we can omit it
def __init__(self, file: File, config: MkDocsConfig):
super().__init__(None, file, config)
# Resolve path relative to docs directory for error reporting
docs = os.path.relpath(config.docs_dir)
path = os.path.relpath(file.abs_src_path, docs)
# Read contents and metadata immediately
with open(file.abs_src_path, encoding = "utf-8") as f:
self.markdown = f.read()
# Sadly, MkDocs swallows any exceptions that occur during parsing.
# As we want to provide the best possible authoring experience, we
# need to catch errors early and display them nicely. We decided to
# drop support for MkDocs' MultiMarkdown syntax, because it is not
# correctly implemented anyway. When using MultiMarkdown syntax, all
# date formats are returned as strings and list are not properly
# supported. Thus, we just use the relevants parts of `get_data`.
match: Match = YAML_RE.match(self.markdown)
if not match:
raise PluginError(
f"Error reading metadata of post '{path}' in '{docs}':\n"
f"Expected metadata to be defined but found nothing"
)
# Extract metadata and parse as YAML
try:
self.meta = yaml.load(match.group(1), SafeLoader) or {}
self.markdown = self.markdown[match.end():].lstrip("\n")
# The post's metadata could not be parsed because of a syntax error,
# which we display to the user with a nice error message
except Exception as e:
raise PluginError(
f"Error reading metadata of post '{path}' in '{docs}':\n"
f"{e}"
)
# Initialize post configuration, but remove all keys that this plugin
# doesn't care about, or they will be reported as invalid configuration
self.config: PostConfig = PostConfig(file.abs_src_path)
self.config.load_dict({
key: self.meta[key] for key in (
set(self.meta.keys()) &
set(self.config.keys())
)
})
# Validate configuration and throw if errors occurred
errors, warnings = self.config.validate()
for _, w in warnings:
log.warning(w)
for k, e in errors:
raise PluginError(
f"Error reading metadata '{k}' of post '{path}' in '{docs}':\n"
f"{e}"
)
# Excerpts are subsets of posts that are used in views like archive and
# category views. They are not rendered as standalone pages, but are
# included in the context of the parent post. Each post has a dedicated
# excerpt instance which is reused when rendering views.
self.excerpt: Excerpt = None
# Initialize authors and actegories
self.authors: list[Author] = []
self.categories: list[Category] = []
# Ensure template is set or use default
self.meta.setdefault("template", "blog-post.html")
# Ensure template hides navigation
self.meta["hide"] = self.meta.get("hide", [])
if "navigation" not in self.meta["hide"]:
self.meta["hide"].append("navigation")
# The contents and metadata were already read in the constructor (and not
# in `read_source` as for pages), so this function must be set to a no-op
def read_source(self, config: MkDocsConfig):
pass
# -----------------------------------------------------------------------------
# Excerpt
class Excerpt(Page):
# Initialize an excerpt for the given post - we create the Markdown parser
# when intitializing the excerpt in order to improve rendering performance
# for excerpts, as they are reused across several different views, because
# posts might be referenced from multiple different locations
def __init__(self, post: Post, config: MkDocsConfig, files: Files):
self.file = copy(post.file)
self.post = post
# Initialize configuration, contents and metadata
self.config = post.config
self.markdown = post.markdown
self.meta = post.meta
# Initialize authors and categories - note that views usually contain
# subsets of those lists, which is why we need to manage them here
self.authors: list[Author] = []
self.categories: list[Category] = []
# Initialize parser - note that we need to patch the configuration,
# more specifically the table of contents extension
config = _patch(config)
self.md = Markdown(
extensions = config.markdown_extensions,
extension_configs = config.mdx_configs,
)
# Register excerpt tree processor - this processor resolves anchors to
# posts from within views, so they point to the correct location
self.md.treeprocessors.register(
ExcerptTreeprocessor(post),
"excerpt",
0
)
# Register relative path tree processor - this processor resolves links
# to other pages and assets, and is used by MkDocs itself
self.md.treeprocessors.register(
_RelativePathTreeprocessor(self.file, files, config),
"relpath",
1
)
# Render an excerpt of the post on the given page - note that this is not
# thread-safe because excerpts are shared across views, as it cuts down on
# the cost of initialization. However, if in the future, we decide to render
# posts and views concurrently, we must change this behavior.
def render(self, page: Page, separator: str):
self.file.url = page.url
# Retrieve excerpt tree processor and set page as base
at = self.md.treeprocessors.get_index_for_name("excerpt")
processor: ExcerptTreeprocessor = self.md.treeprocessors[at]
processor.base = page
# Convert Markdown to HTML and extract excerpt
self.content = self.md.convert(self.markdown)
self.content, *_ = self.content.split(separator, 1)
# Extract table of contents and reset post URL - if we wouldn't reset
# the excerpt URL, linking to the excerpt from the view would not work
self.toc = get_toc(getattr(self.md, "toc_tokens", []))
self.file.url = self.post.url
# -----------------------------------------------------------------------------
# View
class View(Page):
# Initialize view
def __init__(self, title: str | None, file: File, config: MkDocsConfig):
super().__init__(title, file, config)
self.parent: Union[View, Section]
# Initialize posts and views
self.posts: list[Post] = []
self.views: list[View] = []
# Initialize pages for pagination
self.pages: list[View] = []
# Set necessary metadata
def read_source(self, config: MkDocsConfig):
super().read_source(config)
# Ensure template is set or use default
self.meta.setdefault("template", "blog.html")
# -----------------------------------------------------------------------------
# Archive view
class Archive(View):
pass
# -----------------------------------------------------------------------------
# Category view
class Category(View):
pass
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
# Patch configuration
def _patch(config: MkDocsConfig):
config = copy(config)
# Copy configuration that needs to be patched
config.validation = copy(config.validation)
config.validation.links = copy(config.validation.links)
config.mdx_configs = copy(config.mdx_configs)
config.mdx_configs["toc"] = copy(config.mdx_configs["toc"])
# In order to render excerpts for posts, we need to make sure that the
# table of contents extension is appropriately configured
config.mdx_configs["toc"] = {
**config.mdx_configs["toc"],
**{
"anchorlink": True, # Render headline as clickable
"baselevel": 2, # Render h1 as h2 and so forth
"permalink": False, # Remove permalinks
"toc_depth": 2 # Remove everything below h2
}
}
# Additionally, we disable link validation when rendering excerpts, because
# invalid links have already been reported when rendering the page
links = config.validation.links
links.not_found = logging.DEBUG
links.absolute_links = logging.DEBUG
links.unrecognized_links = logging.DEBUG
# Return patched configuration
return config
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.blog")

View File

@ -0,0 +1,37 @@
# 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 ListOfItems, Optional, Type
from .options import PostDate
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Post configuration
class PostConfig(Config):
authors = ListOfItems(Type(str), default = [])
categories = ListOfItems(Type(str), default = [])
date = PostDate()
draft = Optional(Type(bool))
readtime = Optional(Type(int))
slug = Optional(Type(str))

View File

@ -0,0 +1,58 @@
# 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 markdown.treeprocessors import Treeprocessor
from mkdocs.structure.pages import Page
from mkdocs.utils import get_relative_url
from xml.etree.ElementTree import Element
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Excerpt tree processor
class ExcerptTreeprocessor(Treeprocessor):
# Initialize excerpt tree processor
def __init__(self, page: Page, base: Page = None):
self.page = page
self.base = base
# Transform HTML after Markdown processing
def run(self, root: Element):
main = True
# We're only interested in anchors, which is why we continue when the
# link does not start with an anchor tag
for el in root.iter("a"):
anchor = el.get("href")
if not anchor.startswith("#"):
continue
# The main headline should link to the post page, not to a specific
# anchor, which is why we remove the anchor in that case
path = get_relative_url(self.page.url, self.base.url)
if main:
el.set("href", path)
else:
el.set("href", path + anchor)
# Main headline has been seen
main = False

View File

@ -0,0 +1,85 @@
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from collections import UserDict
from datetime import date, datetime, time
from mkdocs.config.base import BaseConfigOption, Config, ValidationError
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Date dictionary
class DateDict(UserDict[str, datetime]):
# Initialize date dictionary
def __init__(self, data: dict):
super().__init__(data)
# Initialize date of creation
if "created" in data:
self.created: datetime = data["created"]
def __getattr__(self, name: str):
if name in self.data:
return self.data[name]
# -----------------------------------------------------------------------------
# Post date option
class PostDate(BaseConfigOption[DateDict]):
# Initialize post dates
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Normalize the supported types for post dates to datetime
def pre_validation(self, config: Config, key_name: str):
# If the date points to a scalar value, convert it to a dictionary,
# since we want to allow the user to specify custom and arbitrary date
# values for posts. Currently, only the `created` date is mandatory,
# because it's needed to sort posts for views.
if not isinstance(config[key_name], dict):
config[key_name] = { "created": config[key_name] }
# Initialize date dictionary and convert all date values to datetime
config[key_name] = DateDict(config[key_name])
for key, value in config[key_name].items():
if isinstance(value, date):
config[key_name][key] = datetime.combine(value, time())
# Ensure each date value is of type datetime
def run_validation(self, value: DateDict):
for key in value:
if not isinstance(value[key], datetime):
raise ValidationError(
f"Expected type: {date} or {datetime} "
f"but received: {type(value[key])}"
)
# Ensure presence of `date.created`
if not value.created:
raise ValidationError(
"Expected 'created' date when using dictionary syntax"
)
# Return date dictionary
return value

View File

@ -0,0 +1,42 @@
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from jinja2 import pass_context
from jinja2.runtime import Context
from material.plugins.blog.structure import View
from mkdocs.utils.templates import url_filter as _url_filter
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Filter for normalizing URLs with support for paginated views
@pass_context
def url_filter(context: Context, url: str | None):
page = context["page"]
# If the current page is a view, check if the URL links to the page
# itself, and replace it with the URL of the main view
if isinstance(page, View):
if page.url == url:
url = page.pages[0].url
# Forward to original template filter
return _url_filter(context, url)

View File

@ -0,0 +1,19 @@
# 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.

View File

@ -0,0 +1,35 @@
# 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
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Info plugin configuration
class InfoConfig(Config):
enabled = Type(bool, default = True)
enabled_on_serve = Type(bool, default = False)
# Options for archive
archive = Type(bool, default = True)
archive_stop_on_violation = Type(bool, default = True)

View File

@ -26,40 +26,38 @@ import requests
import sys
from colorama import Fore, Style
from importlib.metadata import distributions, version
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 markdown.extensions.toc import slugify
from mkdocs.plugins import BasePlugin, event_priority
from mkdocs.structure.files import get_files
from pkg_resources import get_distribution, working_set
from mkdocs.utils import get_theme_dir
from zipfile import ZipFile, ZIP_DEFLATED
from .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)
# Classes
# -----------------------------------------------------------------------------
# Info plugin
class InfoPlugin(BasePlugin[InfoPluginConfig]):
class InfoPlugin(BasePlugin[InfoConfig]):
# Determine whether we're serving
# Initialize plugin
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize incremental builds
self.is_serve = False
# Determine whether we're serving the site
def on_startup(self, *, command, dirty):
self.is_serve = (command == "serve")
self.is_serve = command == "serve"
# Initialize plugin (run earliest)
# Create a self-contained example (run earliest) - determine all files that
# are visible to MkDocs and are used to build the site, create an archive
# that contains all of them, and print a summary of the archive contents.
# The user must attach this archive to the bug report.
@event_priority(100)
def on_config(self, config):
if not self.config.enabled:
@ -76,11 +74,11 @@ class InfoPlugin(BasePlugin[InfoPluginConfig]):
res = requests.get(url, allow_redirects = False)
# Check if we're running the latest version
_, version = res.headers.get("location").rsplit("/", 1)
package = get_distribution("mkdocs-material")
if not package.version.startswith(version):
_, current = res.headers.get("location").rsplit("/", 1)
present = version("mkdocs-material")
if not present.startswith(current):
log.error("Please upgrade to the latest version.")
self._help_on_versions_and_exit(package.version, version)
self._help_on_versions_and_exit(present, current)
# Print message that we're creating a bug report
log.info("Started archive creation for bug report")
@ -89,8 +87,8 @@ class InfoPlugin(BasePlugin[InfoPluginConfig]):
# hack to detect whether the custom_dir setting was used without parsing
# mkdocs.yml again - we check at which position the directory provided
# by the theme resides, and if it's not the first one, abort.
base = utils.get_theme_dir(config.theme.name)
if config.theme.dirs.index(base):
path = get_theme_dir(config.theme.name)
if config.theme.dirs.index(path):
log.error("Please remove 'custom_dir' setting.")
self._help_on_customizations_and_exit()
@ -102,33 +100,39 @@ class InfoPlugin(BasePlugin[InfoPluginConfig]):
log.error("Please remove 'hooks' setting.")
self._help_on_customizations_and_exit()
# Create in-memory archive
# Create in-memory archive and prompt user to enter a short descriptive
# name for the archive, which is also used as the directory name. Note
# that the name is slugified for better readability and stripped of any
# file extension that the user might have entered.
archive = BytesIO()
archive_name = self.config.archive_name
example = input("\nPlease name your bug report (2-4 words): ")
example, _ = os.path.splitext(example)
example = slugify(example, "-")
# Create self-contained example from project
files = []
files: list[str] = []
with ZipFile(archive, "a", ZIP_DEFLATED, False) as f:
for path in ["mkdocs.yml", "requirements.txt"]:
if os.path.isfile(path):
f.write(path, os.path.join(archive_name, path))
f.write(path, os.path.join(example, path))
# Append all files visible to MkDocs
for file in get_files(config):
path = os.path.relpath(file.abs_src_path, os.path.curdir)
f.write(path, os.path.join(archive_name, path))
f.write(path, os.path.join(example, path))
# Add information on installed packages
f.writestr(
os.path.join(archive_name, "requirements.lock.txt"),
os.path.join(example, "requirements.lock.txt"),
"\n".join(sorted([
f"{dist.as_requirement()}" for dist in working_set
"==".join([package.name, package.version])
for package in distributions()
]))
)
# Add information in platform
f.writestr(
os.path.join(archive_name, "platform.json"),
os.path.join(example, "platform.json"),
json.dumps(
{
"system": platform.platform(),
@ -148,7 +152,7 @@ class InfoPlugin(BasePlugin[InfoPluginConfig]):
# Finally, write archive to disk
buffer = archive.getbuffer()
with open(f"{archive_name}.zip", "wb") as f:
with open(f"{example}.zip", "wb") as f:
f.write(archive.getvalue())
# Print summary
@ -172,7 +176,7 @@ class InfoPlugin(BasePlugin[InfoPluginConfig]):
if buffer.nbytes > 1000000:
log.warning("Archive exceeds recommended maximum size of 1 MB")
# Aaaaaand done.
# Aaaaaand done
sys.exit(1)
# -------------------------------------------------------------------------
@ -235,5 +239,4 @@ def _size(value, factor = 1):
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs")
log.addFilter(DuplicateFilter())
log = logging.getLogger("mkdocs.material.info")

View File

@ -0,0 +1,19 @@
# 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.

View File

@ -0,0 +1,30 @@
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from mkdocs.config.config_options import Type
from mkdocs.config.base import Config
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Offline plugin configuration
class OfflineConfig(Config):
enabled = Type(bool, default = True)

View File

@ -20,50 +20,48 @@
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 mkdocs.utils import write_file
from .config import OfflineConfig
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Offline plugin configuration scheme
class OfflinePluginConfig(Config):
enabled = opt.Type(bool, default = True)
# Classes
# -----------------------------------------------------------------------------
# Offline plugin
class OfflinePlugin(BasePlugin[OfflinePluginConfig]):
class OfflinePlugin(BasePlugin[OfflineConfig]):
# Initialize plugin
# Set configuration for offline build
def on_config(self, config):
if not self.config.enabled:
return
# Ensure correct resolution of links
# Ensure correct resolution of links when viewing the site from the
# file system by disabling directory URLs
config.use_directory_urls = False
# Support offline search (run latest)
# Append iframe-worker to polyfills/shims
config.extra.polyfills = config.extra.get("polyfills", [])
if not any("iframe-worker" in url for url in config.extra.polyfills):
worker = "https://unpkg.com/iframe-worker/shim"
config.extra.polyfills.append(worker)
# Add support for offline search (run latest) - the search index is copied
# and inlined into a script, so that it can be used without a server
@event_priority(-100)
def on_post_build(self, *, config):
if not self.config.enabled:
return
# Check for existence of search index
base = os.path.join(config.site_dir, "search")
path = os.path.join(base, "search_index.json")
if not os.path.exists(path):
path = os.path.join(config.site_dir, "search", "search_index.json")
if not os.path.isfile(path):
return
# Retrieve search index
with open(path, "r") as data:
index = data.read()
# Inline search index into script
utils.write_file(
f"var __index = {index}".encode("utf-8"),
os.path.join(base, "search_index.js")
# Create script with inlined search index
with open(path, encoding = "utf-8") as f:
write_file(
f"var __index = {f.read()}".encode("utf-8"),
path.replace(".json", ".js"),
)

View File

@ -0,0 +1,19 @@
# 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.

View File

@ -0,0 +1,55 @@
# 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
# -----------------------------------------------------------------------------
# Options
# -----------------------------------------------------------------------------
# Options for search pipeline
pipeline = ("stemmer", "stopWordFilter", "trimmer")
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Search plugin configuration
class SearchConfig(Config):
lang = Optional(LangOption())
separator = Optional(Type(str))
pipeline = ListOfItems(Choice(pipeline), default = [])
# Options for text segmentation (Chinese)
jieba_dict = Optional(Type(str))
jieba_dict_user = Optional(Type(str))
# Unsupported options, originally implemented in MkDocs
indexing = Deprecated(message = "Unsupported option")
prebuild_index = Deprecated(message = "Unsupported option")
min_search_length = Deprecated(message = "Unsupported option")

View File

@ -26,43 +26,36 @@ import regex as re
from html import escape
from html.parser import HTMLParser
from mkdocs import utils
from mkdocs.commands.build import DuplicateFilter
from mkdocs.config import config_options as opt
from mkdocs.config.base import Config
from mkdocs.contrib.search import LangOption
from mkdocs.plugins import BasePlugin
from .config import SearchConfig
try:
import jieba
except ImportError:
jieba = None
# -----------------------------------------------------------------------------
# Class
# -----------------------------------------------------------------------------
# Search plugin configuration scheme
class SearchPluginConfig(Config):
lang = opt.Optional(LangOption())
separator = opt.Optional(opt.Type(str))
pipeline = opt.ListOfItems(
opt.Choice(("stemmer", "stopWordFilter", "trimmer")),
default = []
)
# Deprecated options
indexing = opt.Deprecated(message = "Unsupported option")
prebuild_index = opt.Deprecated(message = "Unsupported option")
min_search_length = opt.Deprecated(message = "Unsupported option")
# Classes
# -----------------------------------------------------------------------------
# Search plugin
class SearchPlugin(BasePlugin[SearchPluginConfig]):
class SearchPlugin(BasePlugin[SearchConfig]):
# Determine whether we're running under dirty reload
def on_startup(self, *, command, dirty):
# Initialize plugin
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize incremental builds
self.is_dirtyreload = False
self.is_dirty = dirty
# Initialize search index cache
self.search_index_prev = None
# Determine whether we're serving the site
def on_startup(self, *, command, dirty):
self.is_dirty = dirty
# Initialize plugin
def on_config(self, config):
if not self.config.lang:
@ -85,6 +78,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.isfile(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.isfile(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 +184,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 +270,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 +378,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 +400,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 +545,22 @@ class Parser(HTMLParser):
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs")
log.addFilter(DuplicateFilter())
log = logging.getLogger("mkdocs.material.search")
# Tags that are self-closing
void = set([
"area", # Image map areas
"base", # Document base
"br", # Line breaks
"col", # Table columns
"embed", # External content
"hr", # Horizontal rules
"img", # Images
"input", # Input fields
"link", # Links
"meta", # Metadata
"param", # External parameters
"source", # Image source sets
"track", # Text track
"wbr" # Line break opportunities
"area", # Image map areas
"base", # Document base
"br", # Line breaks
"col", # Table columns
"embed", # External content
"hr", # Horizontal rules
"img", # Images
"input", # Input fields
"link", # Links
"meta", # Metadata
"param", # External parameters
"source", # Image source sets
"track", # Text track
"wbr" # Line break opportunities
])

View File

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

View File

@ -0,0 +1,48 @@
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from mkdocs.config.base import Config
from mkdocs.config.config_options import Deprecated, Type
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Social plugin configuration
class SocialConfig(Config):
enabled = Type(bool, default = True)
cache_dir = Type(str, default = ".cache/plugin/social")
# Options for social cards
cards = Type(bool, default = True)
cards_dir = Type(str, default = "assets/images/social")
cards_layout_options = Type(dict, default = {})
# Deprecated options
cards_color = Deprecated(
option_type = Type(dict, default = {}),
message =
"Deprecated, use 'cards_layout_options.background_color' "
"and 'cards_layout_options.color' with 'default' layout"
)
cards_font = Deprecated(
option_type = Type(str),
message = "Deprecated, use 'cards_layout_options.font_family'"
)

View File

@ -27,54 +27,25 @@ 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 .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'"
)
# Classes
# -----------------------------------------------------------------------------
# Social plugin
class SocialPlugin(BasePlugin[SocialPluginConfig]):
class SocialPlugin(BasePlugin[SocialConfig]):
def __init__(self):
self._executor = concurrent.futures.ThreadPoolExecutor(4)
@ -103,14 +74,6 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]):
value = self.config.cards_font
self.config.cards_layout_options["font_family"] = value
# Check if required dependencies are installed
if not dependencies:
log.error(
"Required dependencies of \"social\" plugin not found. "
"Install with: pip install pillow cairosvg"
)
sys.exit(1)
# Check if site URL is defined
if not config.site_url:
log.warning(

View File

@ -0,0 +1,27 @@
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Casefold a string for comparison when sorting
def casefold(tag: str):
return tag.casefold()

View File

@ -0,0 +1,43 @@
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from functools import partial
from markdown.extensions.toc import slugify
from mkdocs.config.config_options import Optional, Type
from mkdocs.config.base import Config
from . import casefold
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Tags plugin configuration
class TagsConfig(Config):
enabled = Type(bool, default = True)
# Options for tags
tags_file = Optional(Type(str))
tags_extra_files = Type(dict, default = dict())
tags_slugify = Type((type(slugify), partial), default = slugify)
tags_slugify_separator = Type(str, default = "-")
tags_compare = Optional(Type(type(casefold)))
tags_compare_reverse = Type(bool, default = False)
tags_allowed = Type(list, default = [])

View File

@ -24,26 +24,19 @@ import sys
from collections import defaultdict
from markdown.extensions.toc import slugify
from mkdocs import utils
from mkdocs.commands.build import DuplicateFilter
from mkdocs.config.base import Config
from mkdocs.config import config_options as opt
from mkdocs.plugins import BasePlugin
# deprecated, but kept for downward compatibility. Use 'material.plugins.tags'
# as an import source instead. This import is removed in the next major version.
from . import casefold
from .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))
# Classes
# -----------------------------------------------------------------------------
# Tags plugin
class TagsPlugin(BasePlugin[TagsPluginConfig]):
class TagsPlugin(BasePlugin[TagsConfig]):
supports_multiple_instances = True
# Initialize plugin
@ -83,6 +76,10 @@ class TagsPlugin(BasePlugin[TagsPluginConfig]):
if not self.config.enabled:
return
# Skip, if page is excluded
if page.file.inclusion.is_excluded():
return
# Render tags index page
if page.file == self.tags_file:
return self._render_tag_index(markdown)
@ -166,5 +163,4 @@ class TagsPlugin(BasePlugin[TagsPluginConfig]):
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs")
log.addFilter(DuplicateFilter())
log = logging.getLogger("mkdocs.material.tags")

View File

@ -84,6 +84,7 @@ theme:
# Plugins
plugins:
- blog
- search:
separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])'
- minify:
@ -231,10 +232,3 @@ nav:
- Changelog: insiders/changelog.md
- Blog:
- blog/index.md
- 2022:
- blog/posts/blog-support-just-landed.md
- blog/posts/chinese-search-support.md
- 2021:
- blog/posts/the-past-present-and-future.md
- blog/posts/excluding-content-from-search.md
- blog/posts/search-better-faster-smaller.md

67
package-lock.json generated
View File

@ -1,26 +1,21 @@
{
"name": "mkdocs-material",
"version": "9.1.21",
"version": "9.2.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "mkdocs-material",
"version": "9.1.21",
"version": "9.2.0",
"license": "MIT",
"dependencies": {
"array-flat-polyfill": "^1.0.1",
"clipboard": "^2.0.11",
"escape-html": "^1.0.3",
"focus-visible": "^5.2.0",
"fuzzaldrin-plus": "^0.6.0",
"iframe-worker": "^1.0.3",
"lunr": "^2.3.9",
"lunr-languages": "^1.13.0",
"resize-observer-polyfill": "^1.5.1",
"rxjs": "^7.8.1",
"unfetch": "^5.0.0",
"url-polyfill": "^1.1.12"
"rxjs": "^7.8.1"
},
"devDependencies": {
"@fortawesome/fontawesome-free": "^6.4.2",
@ -1647,14 +1642,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/array-flat-polyfill": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/array-flat-polyfill/-/array-flat-polyfill-1.0.1.tgz",
"integrity": "sha512-hfJmKupmQN0lwi0xG6FQ5U8Rd97RnIERplymOv/qpq8AoNKPPAnxJadjFA23FNWm88wykh9HmpLJUUwUtNU/iw==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/array-includes": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz",
@ -4758,14 +4745,6 @@
"node": ">=0.10.0"
}
},
"node_modules/iframe-worker": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/iframe-worker/-/iframe-worker-1.0.3.tgz",
"integrity": "sha512-bzOpLcy1R4vBFGsx2fQ4iMI0ikyUWwt9YKjp6/8KZcpk+FSvZavxWD0gA6hqKksjdsn3u59E2TT7qJlTq4Gn4A==",
"engines": {
"node": ">= 16"
}
},
"node_modules/ignore": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
@ -8385,11 +8364,6 @@
"node": ">=0.10.0"
}
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"node_modules/resolve": {
"version": "1.22.3",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.3.tgz",
@ -9894,11 +9868,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/unfetch": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-5.0.0.tgz",
"integrity": "sha512-3xM2c89siXg0nHvlmYsQ2zkLASvVMBisZm5lF3gFDqfF2xonNStDJyMpvaOBe0a1Edxmqrf2E0HBdmy9QyZaeg=="
},
"node_modules/unique-filename": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz",
@ -10032,11 +10001,6 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-polyfill": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.12.tgz",
"integrity": "sha512-mYFmBHCapZjtcNHW0MDq9967t+z4Dmg5CJ0KqysK3+ZbyoNOWQHksGCTWwDhxGXllkWlOc10Xfko6v4a3ucM6A=="
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -11454,11 +11418,6 @@
"is-array-buffer": "^3.0.1"
}
},
"array-flat-polyfill": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/array-flat-polyfill/-/array-flat-polyfill-1.0.1.tgz",
"integrity": "sha512-hfJmKupmQN0lwi0xG6FQ5U8Rd97RnIERplymOv/qpq8AoNKPPAnxJadjFA23FNWm88wykh9HmpLJUUwUtNU/iw=="
},
"array-includes": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz",
@ -13708,11 +13667,6 @@
"safer-buffer": ">= 2.1.2 < 3.0.0"
}
},
"iframe-worker": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/iframe-worker/-/iframe-worker-1.0.3.tgz",
"integrity": "sha512-bzOpLcy1R4vBFGsx2fQ4iMI0ikyUWwt9YKjp6/8KZcpk+FSvZavxWD0gA6hqKksjdsn3u59E2TT7qJlTq4Gn4A=="
},
"ignore": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
@ -16304,11 +16258,6 @@
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true
},
"resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"resolve": {
"version": "1.22.3",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.3.tgz",
@ -17401,11 +17350,6 @@
"which-boxed-primitive": "^1.0.2"
}
},
"unfetch": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-5.0.0.tgz",
"integrity": "sha512-3xM2c89siXg0nHvlmYsQ2zkLASvVMBisZm5lF3gFDqfF2xonNStDJyMpvaOBe0a1Edxmqrf2E0HBdmy9QyZaeg=="
},
"unique-filename": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz",
@ -17494,11 +17438,6 @@
"punycode": "^2.1.0"
}
},
"url-polyfill": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.12.tgz",
"integrity": "sha512-mYFmBHCapZjtcNHW0MDq9967t+z4Dmg5CJ0KqysK3+ZbyoNOWQHksGCTWwDhxGXllkWlOc10Xfko6v4a3ucM6A=="
},
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "mkdocs-material",
"version": "9.1.21",
"version": "9.2.0",
"description": "Documentation that simply works",
"keywords": [
"mkdocs",
@ -39,18 +39,13 @@
"upgrade:install": "npm install"
},
"dependencies": {
"array-flat-polyfill": "^1.0.1",
"clipboard": "^2.0.11",
"escape-html": "^1.0.3",
"focus-visible": "^5.2.0",
"fuzzaldrin-plus": "^0.6.0",
"iframe-worker": "^1.0.3",
"lunr": "^2.3.9",
"lunr-languages": "^1.13.0",
"resize-observer-polyfill": "^1.5.1",
"rxjs": "^7.8.1",
"unfetch": "^5.0.0",
"url-polyfill": "^1.1.12"
"rxjs": "^7.8.1"
},
"devDependencies": {
"@fortawesome/fontawesome-free": "^6.4.2",

View File

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

View File

@ -21,12 +21,16 @@
# Requirements for core
jinja2>=3.0
markdown>=3.2
mkdocs>=1.5.0
mkdocs>=1.5.2
mkdocs-material-extensions>=1.1
pygments>=2.14
pymdown-extensions>=9.9.1
# Requirements for plugins
babel>=2.10.3
colorama>=0.4
lxml>=4.6
paginate>=0.5.6
readtime>=2.0
regex>=2022.4.24
requests>=2.26

View File

@ -0,0 +1,19 @@
# 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.

View File

@ -80,7 +80,7 @@ export type Translations =
*/
export interface Versioning {
provider: "mike" /* Version provider */
default?: string /* Default version */
default?: string | string[] /* Default version */
}
/**

View File

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

View File

@ -20,7 +20,6 @@
* IN THE SOFTWARE.
*/
import ResizeObserver from "resize-observer-polyfill"
import {
NEVER,
Observable,
@ -37,6 +36,8 @@ import {
tap
} from "rxjs"
import { watchScript } from "../../../script"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
@ -66,15 +67,23 @@ const entry$ = new Subject<ResizeObserverEntry>()
* It's quite important to centralize observation in a single `ResizeObserver`,
* as the performance difference can be quite dramatic, as the link shows.
*
* If the browser doesn't have a `ResizeObserver` implementation available, a
* polyfill is automatically downloaded from unpkg.com. This is also compatible
* with the built-in privacy plugin, which will download the polyfill and put
* it alongside the built site for self-hosting.
*
* @see https://bit.ly/3iIYfEm - Google Groups on performance
*/
const observer$ = defer(() => of(
new ResizeObserver(entries => {
for (const entry of entries)
entry$.next(entry)
})
const observer$ = defer(() => (
typeof ResizeObserver === "undefined"
? watchScript("https://unpkg.com/resize-observer-polyfill")
: of(undefined)
))
.pipe(
map(() => new ResizeObserver(entries => {
for (const entry of entries)
entry$.next(entry)
})),
switchMap(observer => merge(NEVER, of(observer))
.pipe(
finalize(() => observer.disconnect())

View File

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

View File

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

View File

@ -20,10 +20,7 @@
* IN THE SOFTWARE.
*/
import "array-flat-polyfill"
import "focus-visible"
import "unfetch/polyfill"
import "url-polyfill"
import {
EMPTY,

View File

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

View File

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

View File

@ -0,0 +1,88 @@
/*
* Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { EMPTY, Observable, defer } from "rxjs"
import { Component } from "../../../_"
import { Annotation } from "../_"
import { mountAnnotationList } from "../list"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
target$: Observable<HTMLElement> /* Location target observable */
print$: Observable<boolean> /* Media print observable */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Find list element directly following a block
*
* @param el - Annotation block element
*
* @returns List element or nothing
*/
function findList(el: HTMLElement): HTMLElement | undefined {
if (el.nextElementSibling) {
const sibling = el.nextElementSibling as HTMLElement
if (sibling.tagName === "OL")
return sibling
/* Skip empty paragraphs - see https://bit.ly/3r4ZJ2O */
else if (sibling.tagName === "P" && !sibling.children.length)
return findList(sibling)
}
/* Everything else */
return undefined
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount annotation block
*
* @param el - Annotation block element
* @param options - Options
*
* @returns Annotation component observable
*/
export function mountAnnotationBlock(
el: HTMLElement, options: MountOptions
): Observable<Component<Annotation>> {
return defer(() => {
const list = findList(el)
return typeof list !== "undefined"
? mountAnnotationList(list, el, options)
: EMPTY
})
}

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More