Documentation

This commit is contained in:
squidfunk
2025-11-07 12:30:13 +01:00
committed by Martin Donath
parent b583ea7765
commit 3815f607a5
182 changed files with 14470 additions and 1627 deletions

View File

@@ -10,12 +10,13 @@ bug fixes and security updates for Material for MkDocs for 12 months at leas
Read the full announcement on our blog:
https://squidfunk.github.io/mkdocs-material/blog/2025/11/05/zensical/
This release includes all features that were previously exclusively available
as part of the Insiders edition. They are now free for everybody to use. The
projects and typeset plugins turned out to be dead ends, which is why they
are deprecated and not part of Material for MkDocs. The sources of those plugins
are distributed with the project, and need to be re-enabled in pyproject.toml,
so third party contributors can take on maintenance, if desired.
This release includes all features that were previously exclusive to the
Insiders edition. These features are now freely available to everyone.
Note on deprecated plugins: The projects and typeset plugins are included in
this release, but must be considered deprecated. Both plugins proved
unsustainable to maintain and represent architectural dead ends. They are
provided as-is without ongoing support.
Changes:

View File

@@ -4,7 +4,7 @@
### 9.7.0 <small>November 11, 2025</small> { id="9.7.0" }
⚠️ __Material for MkDocs is now in maintenance mode__
!!! warning "Material for MkDocs is now in maintenance mode"
This is the last release of Material for MkDocs that will receive new features.
Going forward, the Material for MkDocs team focuses on [Zensical], a next-gen
@@ -13,15 +13,18 @@ bug fixes and security updates for Material for MkDocs for 12 months at leas
[Read the full announcement on our blog]
This release includes all features that were previously exclusively available
as part of the Insiders edition. They are now free for everybody to use. The
[projects] and [typeset] plugins turned out to be dead ends, which is why they
are deprecated and not part of Material for MkDocs. The sources of those plugins
are distributed with the project, and need to be re-enabled in `pyproject.toml`,
so third party contributors can take on maintenance, if desired.
This release includes all features that were previously exclusive to the
Insiders edition. These features are now freely available to everyone.
__Note on deprecated plugins__: The [projects] and [typeset] plugins are
included in this release, but must be considered deprecated. Both plugins
proved unsustainable to maintain and represent architectural dead ends. They
are provided as-is without ongoing support.
__Changes__:
- Added support for projects plugin (for compat, now deprecated)
- Added support for typeset plugin (for compat, now deprecated)
- Added support for pinned blog posts and author profiles
- Added support for customizing pagination for blog index pages
- Added support for customizing blog category sort order
@@ -42,9 +45,9 @@ __Changes__:
- Fixed #8519: Vector accents do not render when using KaTeX
[Zensical]: https://zensical.org
[Read the full announcement on our blog]: https://squidfunk.github.io/mkdocs-material/blog/2025/11/05/zensical/
[projects]: https://squidfunk.github.io/mkdocs-material/plugins/projects/
[typeset]: https://squidfunk.github.io/mkdocs-material/plugins/typeset/
[Read the full announcement on our blog]: ../blog/posts/zensical.md
[projects]: ../plugins/projects.md
[typeset]: ../plugins/typeset.md
### 9.6.23 <small>November 1, 2025</small> { id="9.6.23" }

View File

@@ -86,27 +86,25 @@ sequenceDiagram
```
1. The first step is that you create a fork of the Material for MkDocs
repository, either [mkdocs-material] or [mkdocs-material-insiders]
(only accessible to sponsors). This provides you with a repository that you
can push changes to. Note that it is not possible to have more than one fork
of a given repository at any point in time. So, the fork you create will be
*the* fork you have.
repository. This provides you with a repository that you can push changes to.
Note that it is not possible to have more than one fork of a given repository
at any point in time. So, the fork you create will be *the* fork you have.
2. Once it is made, clone it to your local machine so you can start working on
1. Once it is made, clone it to your local machine so you can start working on
your changes.
3. All contributions should be made through a 'topic branch' with a name that
2. All contributions should be made through a 'topic branch' with a name that
describes the work being done. This allows you to have more than one piece
of work in progress and, if you are working with the public version, also
shows others clearly that the code contained is work in progress. The topic
branch will be relatively short-lived and will disappear at the end, when
your changes have been incorporated into the codebase.
4. If you intend to make any code changes, as opposed to working on
3. If you intend to make any code changes, as opposed to working on
documentation only, you will need to [set up a development
environment](#setting-up-a-development-environment).
5. Next comes the iterative process of making edits, committing them to your
4. Next comes the iterative process of making edits, committing them to your
clone. Please commit in sensible chunks that constitute a piece of work
instead of committing everything in one go.
@@ -116,23 +114,23 @@ sequenceDiagram
reviewer in mind when committing. In particular, make sure to write
meaningful commit messages.
6. Push your work up to your fork regularly.
5. Push your work up to your fork regularly.
7. You should also keep an eye on changes in the Material for MkDocs repository
6. You should also keep an eye on changes in the Material for MkDocs repository
you cloned. This is especially important if you work takes a while. Please
try and merge any concurrent changes into your fork and into your branch
regularly. You *must* do this at least once before creating a pull request,
so make your life easier and do it more often so as to minimize the risk of
conflicting changes.
8. Once you are happy that your changes are in a state that you can describe
7. Once you are happy that your changes are in a state that you can describe
them in a *draft* pull request, you should create this. Make sure to
reference any previous discussions or issues that gave rise to your work.
Creating a draft is a good way to get *early* feedback on your work from the
maintainer or others. You can explicitly request reviews at points where you
think this would be important.
9. Review your work as if you were the reviewer and fix any issues with your
8. Review your work as if you were the reviewer and fix any issues with your
work so far. Look critically at the diffs of the files that you have changed.
In particular, pay attention to whether the changes are as small as possible
and whether you have follow the general coding style used in the project.
@@ -144,8 +142,6 @@ sequenceDiagram
folder. You may also want to make sure that relevant examples from the
[examples repository] still build fine.
[mkdocs-material]: https://github.com/squidfunk/mkdocs-material
[mkdocs-material-insiders]: https://github.com/squidfunk/mkdocs-material-insiders/
[examples repository]: https://github.com/mkdocs-material/examples
### Finalizing
@@ -243,22 +239,13 @@ repositories on GitHub. This is so that you have a repository on GitHub that
you can push changes to (only maintainers and collaborators have write access
to the original repositories).
Fork the [repository for the public version] if you want to make changes to
code that is in the public version or if you want to make changes to the
documentation. It is a good idea to change the name of the repository by
appending `-fork` so that people who come across it know that they have found a
temporary fork rather then the original or a permanent fork of the project.
You may also want to add a description that clarifies what the repository is for.
Fork the [repository] if you want to make changes to code or to the documentation.
It is a good idea to change the name of the repository by appending `-fork` so
that people who come across it know that they have found a temporary fork rather
than the original or a permanent fork of the project. You may also want to add
a description that clarifies what the repository is for.
[repository for the public version]: https://github.com/squidfunk/mkdocs-material
To make changes to functionality available only within the Insiders version,
fork [the Insiders repository]. Note that the fork will be a private repository.
Please respect the [terms of the Insiders program] and the spirit of the
Sponsorware approach used to maintain and develop Material for MkDocs.
[the Insiders repository]: https://github.com/squidfunk/mkdocs-material-insiders/
[terms of the Insiders program]: https://squidfunk.github.io/mkdocs-material/insiders/license/#fair-use-policy
[repository]: https://github.com/squidfunk/mkdocs-material
### Setting up a development environment
@@ -346,21 +333,13 @@ to build a project to serve as a test suite. It can double as documentation that
shows how your new feature is meant to work.
- Test with relevant examples from the [Material for MkDocs Examples]
repository. Note that to build all examples in one go you need the projects
plugin from Insiders but you can always build the examples individually
using the public version.
repository.
[smoke tests]: https://en.wikipedia.org/wiki/Smoke_testing_(software)
[minimal reproduction]: https://squidfunk.github.io/mkdocs-material/guides/creating-a-reproduction/
[Material for MkDocs Examples]: https://github.com/mkdocs-material/examples
- Ideally, also test the examples in the [examples repository]. If you are
working on the Insiders edition of Material for MkDocs, you can simply start a
build at the top level and the [projects plugin] will build all of the examples
for you. If you are on the public version, you will need to build each
sub-project individually. We appreciate that this is a growing collection of
examples and you may want to prioritize those that are most relevant to the
functionality you change.
- Ideally, also test the examples in the [examples repository].
[examples repository]: https://github.com/mkdocs-material/examples
[projects plugin]: https://squidfunk.github.io/mkdocs-material/plugins/projects/

View File

@@ -8,24 +8,12 @@ This documentation use some symbols for illustration purposes. Before you read
on, please make sure you've made yourself familiar with the following list of
conventions:
### <!-- md:sponsors --> Sponsors only { data-toc-label="Sponsors only" }
The pumping heart symbol denotes that a specific feature or behavior is only
available to sponsors via [Insiders]. Make sure that you have access to
[Insiders] if you want to use the feature.
### <!-- md:version --> Version { data-toc-label="Version" }
The tag symbol in conjunction with a version number denotes when a specific
feature or behavior was added. Make sure you're at least on this version
if you want to use it.
### <!-- md:version insiders- --> Version (Insiders) { data-toc-label="Version (Insiders)" }
The tag symbol with a heart in conjunction with a version number denotes that a
specific feature or behavior was added to the [Insiders] version of Material for
MkDocs.
### <!-- md:default --> Default value { #default data-toc-label="Default value" }
Some properties in `mkdocs.yml` have default values for when the author does not
@@ -89,6 +77,3 @@ added by the author.
Besides plugins, there are some utilities that build on top of MkDocs in order
to provide extended functionality, like for example support for versioning.
[Insiders]: insiders/index.md

View File

@@ -262,38 +262,13 @@ directly in the source of the theme and recompile it.
### Environment setup
First, clone the repository for the edition you want to work on. If
you want to clone the Insiders repository, you need to become a
sponsor first to gain access.
[Insiders]: insiders/index.md
=== "Material for MkDocs"
First, clone the repository:
```
git clone https://github.com/squidfunk/mkdocs-material
cd mkdocs-material
```
=== "Insiders"
You will need to have a GitHub access token [as described in the
Insiders documentation] and make it available in the `$GH_TOKEN`
variable.
``` sh
git clone https://${GH_TOKEN}@github.com/squidfunk/mkdocs-material-insiders.git # (1)!
```
1. If you are using SSH keys for authenticating with GitHub, you can
clone Insiders with this command:
```
git clone git@github.com:squidfunk/mkdocs-material-insiders.git
```
[as described in the Insiders documentation]: insiders/getting-started.md#requirements
Next, create a new [Python virtual environment][venv] and
[activate][venv-activate] it:
@@ -321,17 +296,8 @@ source venv/bin/activate
Then, install all Python dependencies:
=== "Material for MkDocs"
```
pip install -e ".[recommended]"
pip install nodeenv
```
=== "Insiders"
```
pip install -e ".[recommended, imaging]"
pip install -e ".[git, recommended, imaging]"
pip install nodeenv
```
@@ -340,7 +306,6 @@ Then, install all Python dependencies:
[image processing]: plugins/requirements/image-processing.md
Finally, install the [Node.js] LTS version into the Python virtual environment
and install all Node.js dependencies:

View File

@@ -1,32 +0,0 @@
---
status: new
---
# Enterprise Feedback
We highly value the insights of our enterprise users, and we're eager to hear
from you. Your feedback is immensely valuable to us. If you're utilizing
Material for MkDocs in an enterprise context and would like to share your
experiences with us, we'd love to connect and discuss:
- What you are building with it
- What aspects you like about it
- What challenges you are facing
- What could be improved
## Let's Connect
To schedule a convenient appointment, please reach out to us via email at
contact@squidfunk.com and provide us with the following details:
- Your company's name
- How you are using Material for MkDocs
- Any specific questions or topics you'd like to address
Once we have this information, we'll promptly get in touch with you to arrange
a 30-minute call. Please note that this call is exclusively intended for
enterprise users and is not meant for technical support. Instead, it's an
opportunity for us to engage in a casual conversation to better understand your
unique needs.
We look forward to our discussion!

View File

@@ -133,11 +133,7 @@ The following plugins are bundled with the Docker image:
Material for MkDocs only bundles selected plugins in order to keep the size
of the official image small. If the plugin you want to use is not included,
you can add them easily:
=== "Material for MkDocs"
Create a `Dockerfile` and extend the official image:
you can add them easily. Create a `Dockerfile` and extend the official image:
``` Dockerfile title="Dockerfile"
FROM squidfunk/mkdocs-material
@@ -145,17 +141,6 @@ The following plugins are bundled with the Docker image:
RUN pip install mkdocs-glightbox
```
=== "Insiders"
Clone or fork the Insiders repository, and create a file called
`user-requirements.txt` in the root of the repository. Then, add the
plugins that should be installed to the file, e.g.:
``` txt title="user-requirements.txt"
mkdocs-macros-plugin
mkdocs-glightbox
```
Next, build the image with the following command:
```

View File

@@ -758,8 +758,7 @@ The following placeholders are available:
#### <!-- md:setting config.archive_pagination -->
<!-- md:sponsors -->
<!-- md:version insiders-4.44.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to enable or disable pagination for archive pages. The value
@@ -776,8 +775,7 @@ plugins:
#### <!-- md:setting config.archive_pagination_per_page -->
<!-- md:sponsors -->
<!-- md:version insiders-4.44.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `10` -->
Use this setting to change the number of posts rendered per archive page. The
@@ -920,8 +918,7 @@ plugins:
#### <!-- md:setting config.categories_sort_by -->
<!-- md:sponsors -->
<!-- md:version insiders-4.45.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `material.plugins.blog.view_name` -->
Use this setting to specify a custom function for sorting categories. For
@@ -942,8 +939,7 @@ that can be compared while sorting, i.e., a string or number.
#### <!-- md:setting config.categories_sort_reverse -->
<!-- md:sponsors -->
<!-- md:version insiders-4.45.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `false` -->
Use this setting to reverse the order in which categories are sorted. By
@@ -983,8 +979,7 @@ this list. Posts can be assigned to categories by using the [`categories`]
#### <!-- md:setting config.categories_pagination -->
<!-- md:sponsors -->
<!-- md:version insiders-4.44.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to enable or disable pagination for category pages. The value
@@ -1001,8 +996,7 @@ plugins:
#### <!-- md:setting config.categories_pagination_per_page -->
<!-- md:sponsors -->
<!-- md:version insiders-4.44.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `10` -->
Use this setting to change the number of posts rendered per category page. The
@@ -1102,8 +1096,7 @@ The provided path is resolved from the [`docs` directory][mkdocs.docs_dir].
#### <!-- md:setting config.authors_profiles -->
<!-- md:sponsors -->
<!-- md:version insiders-4.46.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `false` -->
Use this setting to enable or disable automatically generated author profiles.
@@ -1120,8 +1113,7 @@ plugins:
#### <!-- md:setting config.authors_profiles_name -->
<!-- md:sponsors -->
<!-- md:version insiders-4.46.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default computed -->
Use this setting to change the title of the authors section the plugin adds to
@@ -1138,8 +1130,7 @@ plugins:
#### <!-- md:setting config.authors_profiles_url_format -->
<!-- md:sponsors -->
<!-- md:version insiders-4.46.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `author/{slug}` -->
Use this setting to change the format string that is used when generating
@@ -1171,8 +1162,7 @@ The following placeholders are available:
#### <!-- md:setting config.authors_profiles_pagination -->
<!-- md:sponsors -->
<!-- md:version insiders-4.46.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to enable or disable pagination for author profiles. The value
@@ -1189,8 +1179,7 @@ plugins:
#### <!-- md:setting config.authors_profiles_pagination_per_page -->
<!-- md:sponsors -->
<!-- md:version insiders-4.46.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `10` -->
Use this setting to change the number of posts rendered per archive page. The
@@ -1207,8 +1196,7 @@ plugins:
#### <!-- md:setting config.authors_profiles_toc -->
<!-- md:sponsors -->
<!-- md:version insiders-4.46.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `false` -->
Use this setting to leverage the table of contents to display post titles on all
@@ -1595,8 +1583,7 @@ draft: true
#### <!-- md:setting meta.pin -->
<!-- md:sponsors -->
<!-- md:version insiders-4.53.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag metadata -->
<!-- md:default `false` -->
<!-- md:flag experimental -->
@@ -1618,8 +1605,7 @@ pin: true
#### <!-- md:setting meta.links -->
<!-- md:sponsors -->
<!-- md:version insiders-4.23.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag metadata -->
<!-- md:default none -->
<!-- md:flag experimental -->

View File

@@ -10,13 +10,7 @@ The optimize plugin automatically identifies and optimizes all media files when
As a result, your site loads significantly faster and yields better rankings in
search engines.
---
<!-- md:sponsors --> __Sponsors only__ this plugin is currently reserved to
[our awesome sponsors].
[building your project]: ../creating-your-site.md#building-your-site
[our awesome sponsors]: ../insiders/index.md
## Objective
@@ -83,8 +77,7 @@ build pipelines tailored to your project:
## Configuration
<!-- md:sponsors -->
<!-- md:version insiders-4.29.0 -->
<!-- md:version 9.7.0 -->
<!-- md:plugin [optimize] built-in -->
<!-- md:flag multiple -->
<!-- md:flag experimental -->
@@ -118,8 +111,7 @@ The following settings are available:
#### <!-- md:setting config.enabled -->
<!-- md:sponsors -->
<!-- md:version insiders-4.29.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to enable or disable the plugin when [building your project].
@@ -138,8 +130,7 @@ This configuration enables the plugin only during continuous integration (CI).
#### <!-- md:setting config.concurrency -->
<!-- md:sponsors -->
<!-- md:version insiders-4.29.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default available CPUs - 1 -->
With more CPUs available, the plugin can do more work in parallel, and thus
@@ -169,8 +160,7 @@ The following settings are available for caching:
#### <!-- md:setting config.cache -->
<!-- md:sponsors -->
<!-- md:version insiders-4.29.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to instruct the plugin to bypass the cache, in order to
@@ -188,8 +178,7 @@ plugins:
#### <!-- md:setting config.cache_dir -->
<!-- md:sponsors -->
<!-- md:version insiders-4.29.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `.cache/plugin/optimize` -->
It is normally not necessary to specify this setting, except for when you want
@@ -224,8 +213,7 @@ The following settings are available for optimization:
#### <!-- md:setting config.optimize -->
<!-- md:sponsors -->
<!-- md:version insiders-4.41.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to enable or disable media file optimization. Currently,
@@ -243,8 +231,7 @@ plugins:
#### <!-- md:setting config.optimize_png -->
<!-- md:sponsors -->
<!-- md:version insiders-4.29.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to enable or disable the optimization of `.png` files. It's
@@ -261,8 +248,7 @@ plugins:
#### <!-- md:setting config.optimize_png_speed -->
<!-- md:sponsors -->
<!-- md:version insiders-4.29.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `3` of `1-10` -->
Use this setting to specify the speed/quality tradeoff that [pngquant] applies
@@ -291,8 +277,7 @@ A factor of `10` has 5% lower quality, but is 8x faster than the default `3`.
#### <!-- md:setting config.optimize_png_strip -->
<!-- md:sponsors -->
<!-- md:version insiders-4.29.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to specify whether [pngquant] should strip optional metadata
@@ -311,8 +296,7 @@ plugins:
#### <!-- md:setting config.optimize_jpg -->
<!-- md:sponsors -->
<!-- md:version insiders-4.29.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to enable or disable the optimization of `.jpg` files. It's
@@ -329,8 +313,7 @@ plugins:
#### <!-- md:setting config.optimize_jpg_quality -->
<!-- md:sponsors -->
<!-- md:version insiders-4.29.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `60` of `0-100` -->
Use this setting to specify the image quality that [Pillow] applies when
@@ -347,8 +330,7 @@ plugins:
#### <!-- md:setting config.optimize_jpg_progressive -->
<!-- md:sponsors -->
<!-- md:version insiders-4.29.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to specify whether [Pillow] should use progressive encoding
@@ -367,8 +349,7 @@ plugins:
#### <!-- md:setting config.optimize_include -->
<!-- md:sponsors -->
<!-- md:version insiders-4.41.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default none -->
Use this setting to enable media file optimization for specific directories
@@ -390,8 +371,7 @@ in the `screenshots` folder and its subfolders inside the [`docs` directory]
#### <!-- md:setting config.optimize_exclude -->
<!-- md:sponsors -->
<!-- md:version insiders-4.41.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default none -->
Use this setting to disable media file optimization for specific directories
@@ -417,8 +397,7 @@ The following settings are available for reporting:
#### <!-- md:setting config.print_gain -->
<!-- md:sponsors -->
<!-- md:version insiders-4.29.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to control whether the plugin should print the number of bytes
@@ -434,8 +413,7 @@ plugins:
#### <!-- md:setting config.print_gain_summary -->
<!-- md:sponsors -->
<!-- md:version insiders-4.29.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to control whether the plugin should print the total number of

View File

@@ -221,8 +221,7 @@ The following settings are available for logging:
#### <!-- md:setting config.log -->
<!-- md:sponsors -->
<!-- md:version insiders-4.50.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to control whether the plugin should display log messages when
@@ -238,8 +237,7 @@ plugins:
#### <!-- md:setting config.log_level -->
<!-- md:sponsors -->
<!-- md:version insiders-4.50.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `info` -->
Use this setting to control the log level that the plugin should employ when
@@ -356,8 +354,7 @@ This configuration stores the downloaded copies at `my/custom/dir` in the
#### <!-- md:setting config.assets_include -->
<!-- md:sponsors -->
<!-- md:version insiders-4.37.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default none -->
Use this setting to enable downloading of external assets for specific origins,
@@ -375,8 +372,7 @@ plugins:
#### <!-- md:setting config.assets_exclude -->
<!-- md:sponsors -->
<!-- md:version insiders-4.37.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default none -->
Use this setting to disable downloading of external assets for specific origins,
@@ -415,8 +411,7 @@ The following settings are available for external links:
#### <!-- md:setting config.links -->
<!-- md:sponsors -->
<!-- md:version insiders-4.37.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to instruct the plugin to parse and process external links to
@@ -436,8 +431,7 @@ plugins:
#### <!-- md:setting config.links_attr_map -->
<!-- md:sponsors -->
<!-- md:version insiders-4.37.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default none -->
Use this setting to specify additional attributes that should be added to
@@ -455,8 +449,7 @@ plugins:
#### <!-- md:setting config.links_noopener -->
<!-- md:sponsors -->
<!-- md:version insiders-4.37.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
It is normally not recommended to change this setting, as it will automatically

View File

@@ -1,6 +1,7 @@
---
title: Built-in projects plugin
icon: material/folder-open
status: deprecated
---
# Built-in projects plugin
@@ -10,12 +11,20 @@ distinct projects, build them concurrently and preview them together as one.
This is particularly useful when creating a multi-language project, but can also
be used to split very large projects into smaller parts.
!!! bug "The built-in projects plugin is deprecated"
[Material for MkDocs is in maintenance mode]. The projects plugin, which was
formely part of the [Insiders] edition, was released in <!-- md:version 9.7.0 -->, the last release that includes all features from the Insiders edition.
Unfortunately, the projects plugin turned out impossible to maintain, and
was one of the key motivators to create [Zensical].
---
<!-- md:sponsors --> __Sponsors only__ this plugin is currently reserved to
[our awesome sponsors].
__If you're considering the projects plugin, please be aware that known issues will <u>not</u> be fixed.__
[our awesome sponsors]: ../insiders/index.md
[Material for MkDocs is in maintenance mode]: https://github.com/squidfunk/mkdocs-material/issues/8523
[Zensical]: ../blog/posts/zensical.md
[Insiders]: ../insiders/index.md
## Objective
@@ -70,8 +79,7 @@ and building more comfortable.
## Configuration
<!-- md:sponsors -->
<!-- md:version insiders-4.38.0 -->
<!-- md:version 9.7.0 -->
<!-- md:plugin [projects] built-in -->
<!-- md:flag experimental -->
@@ -97,8 +105,7 @@ The following settings are available:
#### <!-- md:setting config.enabled -->
<!-- md:sponsors -->
<!-- md:version insiders-4.38.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to enable or disable the plugin when [building your project].
@@ -119,8 +126,7 @@ This configuration enables the plugin only during continuous integration (CI).
#### <!-- md:setting config.concurrency -->
<!-- md:sponsors -->
<!-- md:version insiders-4.38.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default available CPUs - 1 -->
With more CPUs available, the plugin can do more work in parallel, and thus
@@ -150,8 +156,7 @@ The following settings are available for caching:
#### <!-- md:setting config.cache -->
<!-- md:sponsors -->
<!-- md:version insiders-4.38.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to instruct the plugin to bypass the cache, in order to
@@ -169,8 +174,7 @@ plugins:
#### <!-- md:setting config.cache_dir -->
<!-- md:sponsors -->
<!-- md:version insiders-4.38.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `.cache/plugin/projects` -->
It is normally not necessary to specify this setting, except for when you want
@@ -191,8 +195,7 @@ The following settings are available for logging:
#### <!-- md:setting config.log -->
<!-- md:sponsors -->
<!-- md:version insiders-4.47.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to control whether the plugin should display log messages from
@@ -209,8 +212,7 @@ plugins:
#### <!-- md:setting config.log_level -->
<!-- md:sponsors -->
<!-- md:version insiders-4.47.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `info` -->
Use this setting to control the log level that the plugin should employ when
@@ -266,8 +268,7 @@ The following settings are available for projects:
#### <!-- md:setting config.projects -->
<!-- md:sponsors -->
<!-- md:version insiders-4.38.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to enable or disable building of projects. Currently, the
@@ -285,8 +286,7 @@ plugins:
#### <!-- md:setting config.projects_dir -->
<!-- md:sponsors -->
<!-- md:version insiders-4.38.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `projects` -->
Use this setting to change the folder where your projects are located. It's
@@ -309,8 +309,7 @@ The provided path is resolved from the root directory.
#### <!-- md:setting config.projects_config_files -->
<!-- md:sponsors -->
<!-- md:version insiders-4.42.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `*/mkdocs.yml` -->
Use this setting to change the location or name of configuration files the
@@ -358,8 +357,7 @@ The provided path is resolved from the [`projects` directory]
#### <!-- md:setting config.projects_config_transform -->
<!-- md:sponsors -->
<!-- md:version insiders-4.42.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default none -->
Use this setting to transform the configuration of each project as read from
@@ -414,8 +412,7 @@ The following settings are available for hoisting:
#### <!-- md:setting config.hoisting -->
<!-- md:sponsors -->
<!-- md:version insiders-4.39.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to enable or disable hoisting of themes files to the main

View File

@@ -143,8 +143,7 @@ This configuration enables the plugin only during continuous integration (CI).
#### <!-- md:setting config.concurrency -->
<!-- md:sponsors -->
<!-- md:version insiders-4.33.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default available CPUs - 1 -->
With more CPUs available, the plugin can do more work in parallel, and thus
@@ -174,8 +173,7 @@ The following settings are available for caching:
#### <!-- md:setting config.cache -->
<!-- md:sponsors -->
<!-- md:version insiders-4.33.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to instruct the plugin to bypass the cache, in order to
@@ -220,8 +218,7 @@ The following settings are available for logging:
#### <!-- md:setting config.log -->
<!-- md:sponsors -->
<!-- md:version insiders-4.40.2 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to control whether the plugin should only log errors when
@@ -238,8 +235,7 @@ plugins:
#### <!-- md:setting config.log_level -->
<!-- md:sponsors -->
<!-- md:version insiders-4.40.2 -->
<!-- md:version 9.7.0 -->
<!-- md:default `warn` -->
Use this setting to control the log level that the plugin should employ when
@@ -323,8 +319,7 @@ This configuration stores the generated images at `my/custom/dir` in the
#### <!-- md:setting config.cards_layout_dir -->
<!-- md:sponsors -->
<!-- md:version insiders-4.33.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `layouts` -->
If you want to build a [custom social card layout][custom layouts], use this
@@ -360,8 +355,7 @@ The provided path is resolved from the root directory.
#### <!-- md:setting config.cards_layout -->
<!-- md:sponsors -->
<!-- md:version insiders-4.33.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `default` -->
The plugin ships a growing list of [`default` layouts][default layouts] for
@@ -434,8 +428,7 @@ defining which parts of your layout can be parametrized. The [`default` layouts]
#### <!-- md:setting config.cards_include -->
<!-- md:sponsors -->
<!-- md:version insiders-4.35.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default none -->
Use this setting to enable social card generation for subsections of your
@@ -457,8 +450,7 @@ contained in the `blog` folder and its subfolders inside the [`docs` directory]
#### <!-- md:setting config.cards_exclude -->
<!-- md:sponsors -->
<!-- md:version insiders-4.35.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default none -->
Use this setting to disable social card generation for subsections of your
@@ -488,8 +480,7 @@ The following settings are available for debugging:
#### <!-- md:setting config.debug -->
<!-- md:sponsors -->
<!-- md:version insiders-4.33.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `false` -->
Use this setting to enable a special mode for debugging your layout, which
@@ -507,8 +498,7 @@ plugins:
#### <!-- md:setting config.debug_on_build -->
<!-- md:sponsors -->
<!-- md:version insiders-4.34.1 -->
<!-- md:version 9.7.0 -->
<!-- md:default `false` -->
By default, the plugin automatically disables [`debug`][config.debug] mode when
@@ -528,8 +518,7 @@ be a safety net.
#### <!-- md:setting config.debug_grid -->
<!-- md:sponsors -->
<!-- md:version insiders-4.33.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
When [`debug`][config.debug] mode is enabled, this setting specifies whether a
@@ -546,8 +535,7 @@ plugins:
#### <!-- md:setting config.debug_grid_step -->
<!-- md:sponsors -->
<!-- md:version insiders-4.33.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `32` -->
Use this setting to specify the step size of the dot grid in pixels, if enabled,
@@ -564,8 +552,7 @@ plugins:
#### <!-- md:setting config.debug_color -->
<!-- md:sponsors -->
<!-- md:version insiders-4.33.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `grey` -->
Use this setting to specify the color of the outlines that are added to each
@@ -600,8 +587,7 @@ The following properties are available:
#### <!-- md:setting meta.social.cards -->
<!-- md:sponsors -->
<!-- md:version insiders-4.37.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag metadata -->
<!-- md:default none -->
@@ -622,8 +608,7 @@ social:
#### <!-- md:setting meta.social.cards_layout -->
<!-- md:sponsors -->
<!-- md:version insiders-4.37.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag metadata -->
<!-- md:default none -->
<!-- md:flag experimental -->
@@ -645,8 +630,7 @@ social:
#### <!-- md:setting meta.social.cards_layout_options -->
<!-- md:sponsors -->
<!-- md:version insiders-4.37.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag metadata -->
<!-- md:default none -->
@@ -669,8 +653,7 @@ Setting an option to `#!yaml null` resets the option.
### Layouts
<!-- md:sponsors -->
<!-- md:version insiders-4.33.0 -->
<!-- md:version 9.7.0 -->
While it is possible and simple to build [custom layouts], the plugin ships
several predefined layouts, all of which are prefixed with `default`. The
@@ -871,8 +854,7 @@ plugins:
#### <!-- md:setting option.background_image -->
<!-- md:sponsors -->
<!-- md:version insiders-4.33.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default none -->
Use this option to define a background image for the generated social card. Note
@@ -985,8 +967,7 @@ no further configuration needed.
#### <!-- md:setting option.font_variant -->
<!-- md:sponsors -->
<!-- md:version insiders-4.53.3 -->
<!-- md:version 9.7.0 -->
<!-- md:default none -->
Use this option to change the font variant used to generate the social card.
@@ -1008,8 +989,7 @@ plugin is instructed to use combinations like `Condensed Regular` or
#### <!-- md:setting option.logo -->
<!-- md:sponsors -->
<!-- md:version insiders-4.40.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default computed -->
Use this option to change the logo that is used in the generated social card.
@@ -1032,8 +1012,7 @@ The provided path is resolved from the root directory.
#### <!-- md:setting option.title -->
<!-- md:sponsors -->
<!-- md:version insiders-4.40.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default computed -->
Use this option to change the title of the generated social card. This overrides
@@ -1053,8 +1032,7 @@ plugins:
#### <!-- md:setting option.description -->
<!-- md:sponsors -->
<!-- md:version insiders-4.40.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default computed -->
Use this option to change the description of the generated social card. This

View File

@@ -232,8 +232,7 @@ The following placeholders are available:
#### <!-- md:setting config.tags_hierarchy -->
<!-- md:sponsors -->
<!-- md:version insiders-4.48.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `false` -->
<!-- md:flag experimental -->
@@ -251,8 +250,7 @@ plugins:
#### <!-- md:setting config.tags_hierarchy_separator -->
<!-- md:sponsors -->
<!-- md:version insiders-4.48.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `/` -->
<!-- md:flag experimental -->
@@ -531,8 +529,7 @@ Using this setting, listings must now be referenced as such:
#### <!-- md:setting config.listings_toc -->
<!-- md:sponsors -->
<!-- md:version insiders-4.48.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to enable or disable tags showing up in the table of contents.
@@ -553,8 +550,7 @@ The following settings are available for shadow tags:
#### <!-- md:setting config.shadow -->
<!-- md:sponsors -->
<!-- md:version insiders-4.48.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `false` -->
Use this setting to specify whether the plugin should include shadow tags on
@@ -581,8 +577,7 @@ deploy previews:
#### <!-- md:setting config.shadow_on_serve -->
<!-- md:sponsors -->
<!-- md:version insiders-4.48.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to control whether the plugin should include shadow tags on
@@ -601,8 +596,7 @@ plugins:
#### <!-- md:setting config.shadow_tags -->
<!-- md:sponsors -->
<!-- md:version insiders-4.48.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default none -->
The plugin allows to specify a predefined list of shadow tags which can be
@@ -621,8 +615,7 @@ plugins:
#### <!-- md:setting config.shadow_tags_prefix -->
<!-- md:sponsors -->
<!-- md:version insiders-4.48.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default none -->
Use this setting to specify a string that is checked as a prefix for each tag.
@@ -639,8 +632,7 @@ plugins:
#### <!-- md:setting config.shadow_tags_suffix -->
<!-- md:sponsors -->
<!-- md:version insiders-4.48.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default none -->
Use this setting to specify a string that is checked as a suffix for each tag.
@@ -662,8 +654,7 @@ The following settings are available for exporting:
#### <!-- md:setting config.export -->
<!-- md:sponsors -->
<!-- md:version insiders-4.49.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to control whether the plugin creates a `tags.json` file
@@ -680,8 +671,7 @@ plugins:
#### <!-- md:setting config.export_file -->
<!-- md:sponsors -->
<!-- md:version insiders-4.49.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `tags.json` -->
Use this setting to change the path of the file where the exported tags are
@@ -700,8 +690,7 @@ The provided path is resolved from the [`site` directory][mkdocs.site_dir].
#### <!-- md:setting config.export_only -->
<!-- md:sponsors -->
<!-- md:version insiders-4.49.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `false` -->
This setting is solely provided for convenience to disable the rendering of tags
@@ -796,8 +785,7 @@ embedded in:
#### <!-- md:setting listing.shadow -->
<!-- md:sponsors -->
<!-- md:version insiders-4.49.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default computed -->
This setting specifies whether the listing should include shadow tags, which
@@ -830,8 +818,7 @@ basis:
#### <!-- md:setting listing.toc -->
<!-- md:sponsors -->
<!-- md:version insiders-4.48.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default [`listings_toc`][config.listings_toc] -->
This setting specifies whether the listing should render tags inside the table

View File

@@ -1,6 +1,7 @@
---
title: Built-in typeset plugin
icon: material/format-title
status: deprecated
---
# Built-in typeset plugin
@@ -10,12 +11,20 @@ headlines within the navigation and table of contents. This means that code
blocks, icons, emojis and any other inline formatting can be rendered exactly
as defined in the page's content.
!!! bug "The built-in typeset plugin is deprecated"
[Material for MkDocs is in maintenance mode]. The typeset plugin, which was
formely part of the [Insiders] edition, was released in <!-- md:version 9.7.0 -->, the last release that includes all features from the Insiders edition.
Unfortunately, the typeset plugin turned out impossible to maintain, and
was one of the key motivators to create [Zensical].
---
<!-- md:sponsors --> __Sponsors only__ this plugin is currently reserved to
[our awesome sponsors].
__If you're considering the typeset plugin, please be aware that known issues will <u>not</u> be fixed.__
[our awesome sponsors]: ../insiders/index.md
[Material for MkDocs is in maintenance mode]: https://github.com/squidfunk/mkdocs-material/issues/8523
[Zensical]: ../blog/posts/zensical.md
[Insiders]: ../insiders/index.md
## Objective
@@ -44,8 +53,7 @@ interfere with other plugins.
## Configuration
<!-- md:sponsors -->
<!-- md:version insiders-4.27.0 -->
<!-- md:version 9.7.0 -->
<!-- md:plugin [typeset] built-in -->
<!-- md:flag experimental -->
@@ -72,8 +80,7 @@ The following settings are available:
#### <!-- md:setting config.enabled -->
<!-- md:sponsors -->
<!-- md:version insiders-4.27.0 -->
<!-- md:version 9.7.0 -->
<!-- md:default `true` -->
Use this setting to enable or disable the plugin when [building your project].

View File

@@ -19,8 +19,6 @@ documentation. At the root of your repository, create a new GitHub Actions
workflow, e.g. `.github/workflows/ci.yml`, and copy and paste the following
contents:
=== "Material for MkDocs"
``` yaml
name: ci # (1)!
on:
@@ -80,54 +78,6 @@ contents:
...
```
=== "Insiders"
``` yaml
name: ci
on:
push:
branches:
- master
- main
permissions:
contents: write
jobs:
deploy:
runs-on: ubuntu-latest
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@v4
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- uses: actions/setup-python@v5
with:
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v4
with:
key: mkdocs-material-${{ env.cache_id }}
path: ~/.cache # (1)!
restore-keys: |
mkdocs-material-
- run: apt-get install pngquant # (2)!
- run: pip install git+https://${GH_TOKEN}@github.com/squidfunk/mkdocs-material-insiders.git
- run: mkdocs gh-deploy --force
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }} # (3)!
```
1. Some Material for MkDocs plugins use [caching] to speed up repeated
builds, and store the results in the `~/.cache` directory.
2. This step is only necessary if you want to use the
[built-in optimize plugin] to automatically compress images.
3. Remember to set the `GH_TOKEN` repository secret to the value of your
[personal access token] when deploying [Insiders], which can be done
using [GitHub secrets].
Now, when a new commit is pushed to either the `master` or `main` branches,
the static site is automatically built and deployed. Push your changes to see
the workflow in action.
@@ -143,7 +93,6 @@ To publish your site on a custom domain, please refer to the [MkDocs documentati
[GitHub Actions]: https://github.com/features/actions
[MkDocs plugins]: https://github.com/mkdocs/mkdocs/wiki/MkDocs-Plugins
[personal access token]: https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token
[Insiders]: insiders/index.md
[built-in optimize plugin]: plugins/optimize.md
[GitHub secrets]: https://docs.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets
[publishing source branch]: https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site
@@ -175,8 +124,6 @@ by using the [GitLab CI] task runner. At the root of your repository, create a
task definition named `.gitlab-ci.yml` and copy and paste the following
contents:
=== "Material for MkDocs"
``` yaml
pages:
stage: deploy
@@ -198,33 +145,6 @@ contents:
1. Some Material for MkDocs plugins use [caching] to speed up repeated
builds, and store the results in the `~/.cache` directory.
=== "Insiders"
``` yaml
pages:
stage: deploy
image: python:latest
script: # (1)!
- pip install git+https://${GH_TOKEN}@github.com/squidfunk/mkdocs-material-insiders.git
- mkdocs build --site-dir public
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- ~/.cache/ # (2)!
artifacts:
paths:
- public
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
```
1. Remember to set the `GH_TOKEN` repository secret to the value of your
[personal access token] when deploying [Insiders], which can be done
using [masked custom variables].
2. Some Material for MkDocs plugins use [caching] to speed up repeated
builds, and store the results in the `~/.cache` directory.
Now, when a new commit is pushed to the [default branch] (typically `master` or
`main`), the static site is automatically built and deployed. Commit and push
the file to your repository to see the workflow in action.

View File

@@ -86,8 +86,7 @@ theme:
### Code selection button
<!-- md:sponsors -->
<!-- md:version insiders-4.32.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag experimental -->
Code blocks can include a button to allow for the selection of line ranges by
@@ -163,8 +162,7 @@ theme:
#### Custom selectors
<!-- md:sponsors -->
<!-- md:version insiders-4.32.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag experimental -->
Normally, code annotations can only be [placed in comments], as comments can be

View File

@@ -46,8 +46,7 @@ open in a new tab:
=== "... or even me"
You can copy the link of the tab and create a link on the same or any other
page. For example, you can [jump to the third tab above this paragraph][tab_1]
or to the [publishing guide for Insiders][tab_2].
page. For example, you can [jump to the third tab above this paragraph][tab_1].
!!! tip "Readable anchor links"
@@ -66,7 +65,6 @@ or to the [publishing guide for Insiders][tab_2].
Fore more information, please [see the extension guide][slugification].
[tab_1]: #anchor-links--or-even-me
[tab_2]: ../publishing-your-site.md#with-github-actions-insiders
[Python Markdown Extensions]: https://facelessuser.github.io/pymdown-extensions/
[slugification]: ../setup/extensions/python-markdown-extensions.md#+pymdownx.tabbed.slugify

View File

@@ -28,13 +28,12 @@ See additional configuration options:
### Footnote tooltips
<!-- md:sponsors -->
<!-- md:version insiders-4.51.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag experimental -->
[Insiders] allows to render footnotes as inline tooltips, so the user can read
the footnote without leaving the context of the document. Footnote tooltips can
be enabled in `mkdocs.yml` with:
Footnotes can be rendered as inline tooltips, so the user can read the footnote
without leaving the context of the document. Footnote tooltips can be enabled
in `mkdocs.yml` with:
``` yaml
theme:
@@ -46,8 +45,6 @@ __Footnote tooltips are enabled on our documentation__, so to try it out, you
can just hover or focus any footnote on this page or any other page of our
documentation.
[Insiders]: ../insiders/index.md
## Usage
### Adding footnote references

View File

@@ -87,7 +87,6 @@ icon: material/emoticon-happy # (1)!
</div>
</div>
[Insiders]: ../insiders/index.md
[icon search]: icons-emojis.md#search
[navigation tabs]: ../setup/setting-up-navigation.md#navigation-tabs

View File

@@ -321,7 +321,6 @@ The other configuration options of this extension are not officially supported
by Material for MkDocs, which is why they may yield unexpected results. Use
them at your own risk.
[Insiders]: ../insiders/index.md
[git-committers]: https://github.com/ojacques/mkdocs-git-committers-plugin-2
[environment variable]: https://www.mkdocs.org/user-guide/configuration/#environment-variables
[rate limits]: https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting

View File

@@ -11,8 +11,7 @@ further useful automatic optimization techniques.
### Built-in projects plugin
<!-- md:sponsors -->
<!-- md:version insiders-4.38.0 -->
<!-- md:version 9.7.0 -->
<!-- md:plugin [projects] built-in -->
<!-- md:flag experimental -->
@@ -89,8 +88,7 @@ extra:
### Built-in optimize plugin
<!-- md:sponsors -->
<!-- md:version insiders-4.29.0 -->
<!-- md:version 9.7.0 -->
<!-- md:plugin [optimize] built-in -->
<!-- md:flag experimental -->

View File

@@ -309,8 +309,6 @@ Material for MkDocs will now change the color palette each time the operating
system switches between light and dark appearance, even when the user doesn't
reload the site.
[Insiders]: ../insiders/index.md
## Customization
### Custom colors

View File

@@ -99,15 +99,11 @@ The following properties are available for each alternate language:
#### Stay on page
<!-- md:sponsors -->
<!-- md:version insiders-4.47.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag experimental -->
[Insiders] improves the user experience when switching between languages, e.g.,
if language `en` and `de` contain a page with the same path name, the user will
stay on the current page:
=== "Insiders"
When switching between languages, e.g., if language `en` and `de` contain a page
with the same path name, the user will stay on the current page:
```
docs.example.com/en/ -> docs.example.com/de/
@@ -115,19 +111,7 @@ stay on the current page:
docs.example.com/en/bar/ -> docs.example.com/de/bar/
```
=== "Material for MkDocs"
```
docs.example.com/en/ -> docs.example.com/de/
docs.example.com/en/foo/ -> docs.example.com/de/
docs.example.com/en/bar/ -> docs.example.com/de/
```
No configuration is necessary. We're working hard on improving multi-language
support in 2024, including making switching between languages even more seamless
in the future.
[Insiders]: ../insiders/index.md
No configuration is necessary.
### Directionality

View File

@@ -170,7 +170,7 @@ For a list of all settings, please consult the [plugin documentation].
when you want to host assets like images outside of your git repository
in another location to keep them fresh and your repository lean.
Additionally, as of <!-- md:version insiders-4.30.0 -->, the
Additionally, as of <!-- md:version 9.7.0 -->, the
built-in privacy plugin was entirely rewritten and now works perfectly
with the [built-in optimize plugin], which means that external assets
can be passed through the same optimization pipeline as the rest of your
@@ -279,24 +279,6 @@ For a list of all settings, please consult the [plugin documentation].
[built-in privacy plugin]: ../plugins/privacy.md
[preconnect]: https://developer.mozilla.org/en-US/docs/Web/Performance/dns-prefetch
#### Advanced settings
<!-- md:sponsors -->
<!-- md:version insiders-4.50.0 -->
The following advanced settings are currently reserved to our [sponsors]
[Insiders]. They are entirely optional, and don't affect the functionality of
the blog, but can be helpful for customizations:
- [`log`][config.log]
- [`log_level`][config.log_level]
We'll add more settings here, as we discover new use cases.
[Insiders]: ../insiders/index.md
[config.log]: ../plugins/privacy.md#config.log
[config.log_level]: ../plugins/privacy.md#config.log_level
## Customization
### Custom cookies

View File

@@ -49,40 +49,7 @@ nav:
For a list of all settings, please consult the [plugin documentation].
[plugin documentation]: ../plugins/blog.md
#### Advanced settings
<!-- md:sponsors -->
<!-- md:version insiders-4.44.0 -->
The following advanced settings are currently reserved to our [sponsors]
[Insiders]. They are entirely optional, and don't affect the functionality of
the blog, but can be helpful for customizations:
- [`archive_pagination`][config.archive_pagination]
- [`archive_pagination_per_page`][config.archive_pagination_per_page]
- [`categories_sort_by`][config.categories_sort_by]
- [`categories_sort_reverse`][config.categories_sort_reverse]
- [`categories_pagination`][config.categories_pagination]
- [`categories_pagination_per_page`][config.categories_pagination_per_page]
- [`authors_profiles_pagination`][config.authors_profiles_pagination]
- [`authors_profiles_pagination_per_page`][config.authors_profiles_pagination_per_page]
We'll add more settings here, as we discover new use cases.
[Insiders]: ../insiders/index.md
[built-in blog plugin]: ../plugins/blog.md
[built-in plugins]: ../insiders/getting-started.md#built-in-plugins
[start writing your first post]: #writing-your-first-post
[config.archive_pagination]: ../plugins/blog.md#config.archive_pagination
[config.archive_pagination_per_page]: ../plugins/blog.md#config.archive_pagination_per_page
[config.categories_sort_by]: ../plugins/blog.md#config.categories_sort_by
[config.categories_sort_reverse]: ../plugins/blog.md#config.categories_sort_reverse
[config.categories_pagination]: ../plugins/blog.md#config.categories_pagination
[config.categories_pagination_per_page]: ../plugins/blog.md#config.categories_pagination_per_page
[config.authors_profiles_pagination]: ../plugins/blog.md#config.authors_profiles_pagination
[config.authors_profiles_pagination_per_page]: ../plugins/blog.md#config.authors_profiles_pagination_per_page
### RSS
@@ -359,8 +326,7 @@ authors:
#### Adding author profiles
<!-- md:sponsors -->
<!-- md:version insiders-4.46.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag experimental -->
If you wish to add a dedicated page for each author, you can enable author
@@ -521,8 +487,7 @@ all links are correct.
#### Pinning a post
<!-- md:sponsors -->
<!-- md:version insiders-4.53.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag experimental -->
If you want to pin a post to the top of the index page, as well as the archive

View File

@@ -52,8 +52,7 @@ especially useful for large documentation sites.
#### Instant prefetching
<!-- md:sponsors -->
<!-- md:version insiders-4.36.0 -->
<!-- md:version 9.7.0 -->
<!-- md:feature -->
<!-- md:flag experimental -->
@@ -93,8 +92,7 @@ experience.
### Instant previews
<!-- md:sponsors -->
<!-- md:version insiders-4.52.0 -->
<!-- md:version 9.7.0 -->
<!-- md:feature -->
<!-- md:flag experimental -->
@@ -125,8 +123,7 @@ with the `data-preview` attribute:
#### Automatic previews
<!-- md:sponsors -->
<!-- md:version insiders-4.53.0 -->
<!-- md:version 9.7.0 -->
<!-- md:extension -->
<!-- md:flag experimental -->
@@ -337,8 +334,7 @@ theme:
### Navigation path <small>Breadcrumbs</small> { id=navigation-path }
<!-- md:sponsors -->
<!-- md:version insiders-4.28.0 -->
<!-- md:version 9.7.0 -->
<!-- md:feature -->
<!-- md:flag experimental -->
@@ -542,8 +538,7 @@ hide:
### Hiding the navigation path
<!-- md:sponsors -->
<!-- md:version insiders-4.28.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag metadata -->
While the [navigation path] is rendered above the main headline, sometimes, it

View File

@@ -127,7 +127,7 @@ comes with CJK characters, e.g. one from the `Noto Sans` font family:
### Changing the layout
<!-- md:version insiders-4.37.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag metadata -->
<!-- md:flag experimental -->
@@ -154,7 +154,7 @@ the [built-in meta plugin].
### Parametrizing the layout
<!-- md:version insiders-4.37.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag metadata -->
<!-- md:flag experimental -->
@@ -181,7 +181,7 @@ the [built-in meta plugin].
### Disabling social cards
<!-- md:version insiders-4.37.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag metadata -->
<!-- md:flag experimental -->
@@ -200,15 +200,9 @@ social:
## Customization
<!-- md:sponsors -->
<!-- md:version insiders-4.33.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag experimental -->
[Insiders] ships a ground up rewrite of the [built-in social plugin] and
introduces a brand new layout system based on a combination of YAML and
[Jinja templates] the same engine Material for MkDocs uses for HTML
templating allowing for the creation of complex custom layouts:
<div class="mdx-social">
<div class="mdx-social__layer">
<div class="mdx-social__image">
@@ -256,9 +250,9 @@ just once, substantially accelerating card generation. The generated cards are
cached to ensure they are only regenerated when their contents change.
Layouts are written in YAML syntax. Before starting to create a custom layout,
it is a good idea to [study the pre-designed layouts] (link to [Insiders]
repository), in order to get a better understanding of how they work. Then,
create a new layout and reference it in `mkdocs.yml`:
it is a good idea to [study the pre-designed layouts], in order to get a better
understanding of how they work. Then, create a new layout and reference it in
`mkdocs.yml`:
=== ":octicons-file-code-16: `layouts/custom.yml`"
@@ -284,11 +278,10 @@ haven't defined any layers, the cards are transparent.
The following sections explain how to create custom layouts.
[Insiders]: ../insiders/index.md
[built-in social plugin]: ../plugins/social.md
[Google Fonts]: https://fonts.google.com/
[Jinja templates]: https://jinja.palletsprojects.com/en/3.1.x/
[study the pre-designed layouts]: https://github.com/squidfunk/mkdocs-material-insiders/tree/master/src/plugins/social/layouts
[study the pre-designed layouts]: https://github.com/squidfunk/mkdocs-material/tree/master/src/plugins/social/layouts
### Size and offset
@@ -318,7 +311,7 @@ useful for alignment and composition.
#### Origin
<!-- md:version insiders-4.35.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag experimental -->
The `origin` for the `x` and `y` values can be changed, so that the layer is

View File

@@ -27,25 +27,6 @@ For a list of all settings, please consult the [plugin documentation].
[plugin documentation]: ../plugins/tags.md
#### Advanced settings
<!-- md:sponsors -->
<!-- md:version insiders-4.48.0 -->
<!-- md:flag experimental -->
The following advanced settings are currently reserved to our [sponsors]
[Insiders]. They are entirely optional, and only add additional capabilities to
the tags plugin:
<!-- - [`listings_layout`][config.listings_layout] -->
- [`listings_toc`][config.listings_toc]
We'll add more settings here in the near future.
[Insiders]: ../insiders/index.md
[config.listings_layout]: ../plugins/tags.md#config.listings_layout
[config.listings_toc]: ../plugins/tags.md#config.listings_toc
### Tag icons and identifiers
<!-- md:version 8.5.0 -->
@@ -200,15 +181,6 @@ arbitrary content before and after the marker:
### Advanced features
[Insiders] ships a __ground up rewrite of the tags plugin__ which is infinitely
more powerful than the current version in the community edition. It allows
for an arbitrary number of tags indexes (listings), [scoped listings],
[shadow tags], [nested tags], and much more.
[scoped listings]: #scoped-listings
[shadow tags]: #shadow-tags
[nested tags]: #nested-tags
#### Configurable listings
<!-- md:version 9.6.0 -->
@@ -252,8 +224,7 @@ See the [listing configuration] for all options.
#### Scoped listings
<!-- md:sponsors -->
<!-- md:version insiders-4.48.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag experimental -->
If your documentation is large, you might want to consider using scoped listings
@@ -283,8 +254,7 @@ You can now use:
#### Shadow tags
<!-- md:sponsors -->
<!-- md:version insiders-4.48.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag experimental -->
Shadow tags are tags that are solely meant to organization, which can be
@@ -308,14 +278,12 @@ This is an excellent opportunity for using tags for structuring.
#### Nested tags
<!-- md:sponsors -->
<!-- md:version insiders-4.48.0 -->
<!-- md:version 9.7.0 -->
<!-- md:flag experimental -->
[Insiders] ships support for nested tags. The
[`tags_hierarchy_separator`][config.tags_hierarchy_separator] allows to create
hierarchies of tags, e.g., `Foo/Bar`. Nested tags will be rendered as children
of the parent tag:
The [`tags_hierarchy_separator`][config.tags_hierarchy_separator] allows to
create hierarchies of tags, e.g., `Foo/Bar`. Nested tags will be rendered as
children of the parent tag:
``` yaml
plugins:

View File

@@ -147,11 +147,6 @@ extra:
Thousands of hours went into this project, most of them
without any financial return.
Thus, if you remove this notice, please consider [sponsoring][Insiders] the
project. __Thank you__ :octicons-heart-fill-24:{ .mdx-heart .mdx-insiders }
[Insiders]: ../insiders/index.md
## Usage
### Hiding prev/next links

View File

@@ -179,11 +179,10 @@ header to indicate that a post is still in draft form.
stage. Remember to remove the `draft` setting in the header when it is time
to publish it.
If you are using the [Insiders Edition], you can also create
a folder to keep your drafts in and use the [Meta plugin] to add the
`draft` header setting to all the posts in that folder. This has the advantage
that it is easier to see which posts are still in draft form. We will cover the
Meta plugin later on.
You can also create a folder to keep your drafts in and use the [Meta plugin]
to add the `draft` header setting to all the posts in that folder. This has the
advantage that it is easier to see which posts are still in draft form. We will
cover the Meta plugin later on.
[Meta plugin]: ../../plugins/meta.md
@@ -232,11 +231,10 @@ your readers will take the read the post.
### Pinning
Sometimes, blog authors want to 'pin' a specific post so that it will always
appear at the top of the index page, no matter what else gets published. If you
are using the [Insiders Edition], you can achieve this by adding the `pin`
attribute in the page header:
appear at the top of the index page, no matter what else gets published. You can
achieve this by adding the `pin` attribute in the page header:
!!! example "Pin a post <!-- md:sponsors -->"
!!! example "Pin a post"
Add the `pin` attribute to your first blog post:
@@ -261,7 +259,7 @@ will want to provide links from blog posts into your other content. One way you
can do this is to have a related links section. The blog plugin can create one
for you if you provide link targets in your page header:
!!! example "Add a related links section <!-- md:sponsors -->"
!!! example "Add a related links section"
Add the following to a blog post:
@@ -320,7 +318,7 @@ together so that they are not only flagged as drafts but also easier to find.
(Otherwise, you would need to inspect the page headers or trace back from the
output to the files to figure out which posts are drafts.)
!!! example "Drafts using the Meta plugin <!-- md:sponsors -->"
!!! example "Drafts using the Meta plugin"
You first need to activate the plugin in your `mkdocs.yaml`:
@@ -356,7 +354,6 @@ output to the files to figure out which posts are drafts.)
post from draft status to published, simply move it outside `drafts/`.
[meta]: ../../plugins/meta.md
[Insiders Edition]: ../../insiders/index.md
## What's next?

View File

@@ -323,11 +323,10 @@ the page header.
Note that `authors` is a list, so you can specify multiple authors.
With the Insiders edition, you can create custom author index pages that
can highlight the contributions of an author as well as provide additional
information about them.
You can create custom author index pages that can highlight the contributions
of an author as well as provide additional information about them.
!!! example "Add author page <!-- md:sponsors -->"
!!! example "Add author page"
First, you need to enable author profiles in the `mkdocs.yml`:

View File

@@ -11,18 +11,7 @@ gain not only an understanding of how to use Material for MkDocs, but also
a template for your own projects. For convenience, these templates are also
available as template repositories on GitHub.
The tutorials assume that you have installed either the [public version] or the
[Insiders edition] of Material for MkDocs and that you have worked through the
[creating your site] setup guide.
Note that where the features we use require the Insiders edition, we mark these
with the heart icon: <!-- md:sponsors --> If you are using the public version
then you can skip these steps. Sometimes there will be ways of achieving the
same goal that differ between the public version and the Insider edition. In
that case, we will show them in a tabbed view so you can see one or the other.
[public version]: ../getting-started.md
[Insiders edition]: ../insiders/getting-started.md
[creating your site]: ../creating-your-site.md
!!! note "Feedback wanted!"

View File

@@ -43,8 +43,8 @@ the `head` element, including one entry that points to the image.
The social plugin has configuration options for changing aspects such as colors,
images, fonts, logos, the title, even the description. You can configure them
for all social cards in the `mkdocs.yml` and, in the Insiders Edition, they can
be overridden in the page header for individual pages.
for all social cards in the `mkdocs.yml` and, they can be overridden in the page
header for individual pages.
!!! example "Change the background color"
@@ -108,15 +108,11 @@ is 1200x630 pixels, so choose an image that size or one that scales well to it.
## Additional layouts and styles
<!-- md:sponsors -->
The Insiders Edition provides additional layouts as well as the option to
configure different styles for different (kinds of) pages.
The Insiders Edition comes with a number of additional layouts for the social
cards. For example, the `default/variant` layout adds a page icon to the card.
You can use this to distinguish social cards visually, depending on what kind
of page you are sharing.
The social plugin provides additional layouts as well as the option to configure
different styles for different (kinds of) pages. It comes with a number of
additional layouts for the social cards. For example, the `default/variant`
layout adds a page icon to the card. You can use this to distinguish social
cards visually, depending on what kind of page you are sharing.
For example, imagine you have a set of pages that advertise events and you want
to include a calendar icon as a visual indication that a card advertises an
@@ -169,12 +165,9 @@ do this in the [custom social cards tutorial](custom.md).
## Per-page settings
<!-- md:sponsors -->
With the Insiders Edition, you can customize the card layout for each
page by adding settings to the page header. You have effectively done this
in the previous exercise, but using the meta plugin to affect a whole set of
pages.
You can customize the card layout for each page by adding settings to the page
header. You have effectively done this in the previous exercise, but using the
meta plugin to affect a whole set of pages.
Say that in addition to regular events you also have the odd webinar and
for this you want to set a different icon and also set the description to
@@ -196,10 +189,9 @@ indicate that the event is part of the webinar series.
## What's next?
With the Insiders Edition, you can also define custom layouts if the
configuration options introduced above as not enough to meet your needs.
Continue to the [custom social cards tutorial](custom.md) if you want to
find out how to do this.
You can also define custom layouts if the configuration options introduced above
as not enough to meet your needs. Continue to the
[custom social cards tutorial](custom.md) if you want to find out how to do this.
Social cards are particularly useful for blog posts. If you have a blog,
you need to do nothing more than to turn on both plugins to create social cards

View File

@@ -1,6 +1,6 @@
# Custom cards
The Insiders Edition allows you to define custom layouts for your social cards
The social plugin allows you to define custom layouts for your social cards
to suit your specific needs if the configuration options are not enough.
For example, you may want to define a social card to advertise a new release
of your product. It should have an icon indicating a launch announcement as
@@ -12,7 +12,7 @@ You can either design a custom layout from scratch or use an existing layout
as the basis that you add to or otherwise modify. In this tutorial, you will
use the default layout as the basis.
!!! example "Copy default layout to customize <!-- md:sponsors -->"
!!! example "Copy default layout to customize"
Copy the default social card layout from your installation of Material
for MkDocs to a new directory `layouts`. The instructions below assume you
@@ -53,7 +53,7 @@ assumes you have a changelog page with information about each release.
Add the version number of the latest version to the page header (so it does
not need to be parsed out of the Markdown content):
!!! example "Defining the release data <!-- md:sponsors -->"
!!! example "Defining the release data"
Create a page `docs/changelog.md` with the following content:

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

@@ -0,0 +1 @@
{"version":3,"sources":["src/overrides/assets/stylesheets/custom/_typeset.scss","../../../../src/overrides/assets/stylesheets/custom.scss","src/templates/assets/stylesheets/utilities/_break.scss","src/overrides/assets/stylesheets/custom/layout/_banner.scss","src/overrides/assets/stylesheets/custom/layout/_hero.scss","src/overrides/assets/stylesheets/custom/layout/_iconsearch.scss","src/overrides/assets/stylesheets/custom/layout/_sponsorship.scss"],"names":[],"mappings":"AA2BA,iBACE,cAIE,kBC7BF,CDgCA,QAEE,qBC/BF,CACF,CD0CE,qBACE,aCxCJ,CD6CE,sBACE,aC3CJ,CD+CE,uBACE,UC7CJ,CDgDI,8BAGE,QAAA,CACA,sBAAA,CAHA,iBAAA,CACA,UC5CN,CDkDI,8BAOE,WAAA,CAFA,WAAA,CAFA,MAAA,CAGA,eAAA,CALA,iBAAA,CACA,KAAA,CAEA,UC7CN,CDqDE,uBACE,2BCnDJ,CDuDE,0BACE,aCrDJ,CD2DE,uBACE,eCzDJ,CD4DI,8BACE,4BAAA,CACA,4BAAA,CACA,2CAAA,CAEA,aC3DN,CD8DM,uCACE,2BC5DR,CDiEI,8BACE,WAAA,CACA,iBC/DN,CDmEI,uCAGE,4BChEN,CD6DI,uCAGE,6BChEN,CD6DI,uCAIE,+BCjEN,CD6DI,uCAIE,gCCjEN,CD6DI,6BAEE,iDAAA,CADA,aC9DN,CDoEM,wCACE,mBClER,CDuEI,uCAEE,6BCnEN,CDiEI,uCAEE,4BCnEN,CDiEI,uCAGE,gCCpEN,CDiEI,uCAGE,+BCpEN,CDiEI,6BAIE,iEAAA,CAHA,mBClEN,CD4EE,+BACE,cAAA,CACA,uBC1EJ,CD6EI,0EACE,WC3EN,CD+EI,oCAGE,2CAAA,CADA,gCAAA,CADA,aC3EN,CDqFI,wDAEE,cCnFN,CCgII,0CF/CA,wDAMI,eClFN,CACF,CDsFI,4BACE,kBCpFN,CDyFE,uBACE,eCvFJ,CD0FI,0BACE,eCxFN,CD2FM,6BACE,iBCzFR,CD8FI,6BACE,YAAA,CACA,SC5FN,CDgGI,gCACE,YAAA,CACA,MAAA,CACA,qBC9FN,CDiGM,qCAEE,oBAAA,CADA,mBAAA,CAEA,6BC/FR,CDmGM,kDACE,aCjGR,CDqGM,qCACE,WCnGR,CDyGE,wBAEE,sBAAA,CADA,iBCtGJ,CD0GI,iDACE,0BCxGN,CD4GI,+BAEE,eAAA,CADA,iBAAA,CAGA,2BAAA,CADA,uCCzGN,CDgHQ,wDACE,SC9GV,CDkHQ,wDACE,0BChHV,CDoHQ,wDACE,SClHV,CDwHI,+BACE,yCACE,CAGF,oDAAA,CADA,mBCvHN,CD2HM,mCACE,aCzHR,CD8HI,+BAKE,kDAAA,CADA,gCAAA,CAFA,aAAA,CAIA,SAAA,CAHA,mBAAA,CAFA,iBAAA,CAMA,mBC5HN,CDiIM,8DACE,2BC/HR,CD8HM,8DACE,2BC5HR,CD2HM,8DACE,2BCzHR,CDwHM,8DACE,uBCtHR,CDqHM,8DACE,0BCnHR,CDkHM,6DACE,0BChHR,CD+GM,8DACE,0BC7GR,CE3JA,WACE,wCF8JF,CE3JE,kBAEE,kBF6JJ,CE1JE,+BAJE,+BFiKJ,CE1JI,sCAEE,kBF2JN,CEzJM,wDACE,0CAAA,CACA,eF2JR,CEtJE,oBAME,kBAAA,CACA,0CAAA,CANA,oBAAA,CAEA,aAAA,CACA,cAAA,CAIA,mBAAA,CAHA,qBAAA,CAHA,YF8JJ,CEtJI,wBACE,aAAA,CACA,eFwJN,CG3LA,eAEE,uYACE,CAFF,gBH+LF,CGpLE,4CACE,yYHsLJ,CG1KA,UAEE,gCAAA,CADA,cH8KF,CG1KE,aAGE,kBAAA,CADA,eAAA,CADA,kBH8KJ,CCpBI,0CE3JF,aAOI,gBH4KJ,CACF,CGxKE,mBACE,mBH0KJ,CC/CI,mCE7IJ,UAwBI,mBAAA,CADA,YH0KF,CGtKE,mBAGE,iBAAA,CAFA,eAAA,CACA,mBHyKJ,CGpKE,iBACE,OAAA,CAEA,0BAAA,CADA,WHuKJ,CACF,CC/DI,sCEhGA,iBACE,0BHkKJ,CACF,CG9JE,qBAGE,gCAAA,CADA,kBAAA,CADA,gBHkKJ,CG7JI,sDAEE,0CAAA,CACA,sCAAA,CAFA,+BHiKN,CG3JI,8BAEE,2CAAA,CACA,uCAAA,CAFA,aH+JN,CItPE,4BAEE,2CAAA,CACA,mBAAA,CACA,8BAAA,CAHA,iBAAA,CAIA,2BJyPJ,CItPI,2EACE,8BJwPN,CIpPI,sCACE,qCAAA,CACA,eJsPN,CInPM,mEACE,kCJqPR,CI/OE,mCAIE,kCAAA,CAAA,0BAAA,CAHA,eAAA,CACA,eAAA,CAIA,yDAAA,CACA,oBAAA,CAFA,kBJkPJ,CI7OI,+CACE,mBJ+ON,CI3OI,sDAEE,YAAA,CADA,WJ8ON,CIzOI,4DACE,oDJ2ON,CIxOM,kEACE,0CJ0OR,CIrOI,yCAKE,yCAAA,CADA,gBAAA,CAHA,iBAAA,CAEA,WAAA,CADA,SJ0ON,CC9GI,0CG9HA,yCASI,YJuON,CACF,CInOI,2CAOE,qDAAA,CACA,WAAA,CACA,mBAAA,CAHA,uCAAA,CADA,gBAAA,CADA,oBAAA,CAAA,iBAAA,CAHA,iBAAA,CAEA,WAAA,CADA,SAAA,CAQA,6CJqON,CIlOM,kGAGE,0CAAA,CADA,+BAAA,CAEA,YJmOR,CI/NM,wEACE,YJiOR,CI5NI,mDAKE,aJ6NN,CIlOI,mDAKE,cJ6NN,CIlOI,yCAME,eAAA,CAJA,QAAA,CADA,SJiON,CIxNI,mDAKE,aJyNN,CI9NI,mDAKE,cJyNN,CI9NI,yCAME,+DAAA,CAJA,QAAA,CADA,mBJ6NN,CIrNM,oDACE,kBJuNR,CInNM,2CACE,kBJqNR,CIjNM,6CAEE,YAAA,CADA,WJoNR,CIhNQ,0FACE,gBJkNV,CKnVI,2BACE,YAAA,CACA,iBLsVN,CKlVI,6BACE,cLoVN,CKhVI,sCACE,YAAA,CACA,cAAA,CACA,sBLkVN,CK/UM,wCACE,aAAA,CACA,aLiVR,CKxUI,mCACE,YL0UN,CKvUM,yCAEE,UAAA,CACA,UAAA,CAFA,aL2UR,CKpUI,6CAEE,UL6UN,CK/UI,6CAEE,WL6UN,CK/UI,mCAOE,kBAAA,CANA,aAAA,CAGA,aAAA,CACA,YAAA,CACA,eAAA,CAKA,kBAAA,CAHA,sCACE,CANF,YL4UN,CKjUM,kFACE,oBLmUR,CKhUQ,0FACE,mBLkUV,CK7TM,4CAME,+CAAA,CAFA,yCAAA,CAHA,eAAA,CACA,eAAA,CACA,kBAAA,CAEA,iBLgUR,CK3TM,uCACE,aAAA,CAGA,mCAAA,CADA,WAAA,CAEA,uBAAA,CAHA,ULgUR,CKvTE,oCACE,eLyTJ,CKrTE,sEAEE,eLuTJ","file":"custom.css"}

View File

@@ -3,7 +3,7 @@
-#}
{% extends "base.html" %}
{% block extrahead %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/custom.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/custom.a2a27114.min.css' | url }}">
{% endblock %}
{% block announce %}
For updates follow <strong>@squidfunk</strong> on
@@ -23,5 +23,5 @@
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ 'assets/javascripts/custom.js' | url }}"></script>
<script src="{{ 'assets/javascripts/custom.a0168cc8.min.js' | url }}"></script>
{% endblock %}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":3,"sources":["src/templates/assets/stylesheets/palette/_scheme.scss","../../../../src/templates/assets/stylesheets/palette.scss","src/templates/assets/stylesheets/palette/_accent.scss","src/templates/assets/stylesheets/palette/_primary.scss","src/templates/assets/stylesheets/utilities/_break.scss"],"names":[],"mappings":"AA2BA,cAGE,6BAME,sDAAA,CACA,6DAAA,CACA,+DAAA,CACA,gEAAA,CACA,mDAAA,CACA,6DAAA,CACA,+DAAA,CACA,gEAAA,CAGA,mDAAA,CACA,gDAAA,CACA,yDAAA,CACA,4DAAA,CAGA,0BAAA,CACA,mCAAA,CAGA,iCAAA,CACA,kCAAA,CACA,mCAAA,CACA,mCAAA,CACA,kCAAA,CACA,iCAAA,CACA,+CAAA,CACA,6DAAA,CACA,gEAAA,CACA,4DAAA,CACA,4DAAA,CACA,6DAAA,CAGA,6CAAA,CAGA,+CAAA,CAGA,uDAAA,CACA,6DAAA,CACA,2DAAA,CAGA,iCAAA,CAGA,yDAAA,CACA,iEAAA,CAGA,mDAAA,CACA,mDAAA,CAGA,qDAAA,CACA,uDAAA,CAGA,8DAAA,CAKA,8DAAA,CAKA,0DAAA,CAzEA,iBCiBF,CD6DE,kHAEE,YC3DJ,CDkFE,yDACE,4BChFJ,CD+EE,2DACE,4BC7EJ,CD4EE,gEACE,4BC1EJ,CDyEE,2DACE,4BCvEJ,CDsEE,yDACE,4BCpEJ,CDmEE,0DACE,4BCjEJ,CDgEE,gEACE,4BC9DJ,CD6DE,0DACE,4BC3DJ,CD0DE,2OACE,4BC/CJ,CDsDA,+FAGE,iCCpDF,CACF,CCjDE,2BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD6CN,CCvDE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDoDN,CC9DE,8BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD2DN,CCrEE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDkEN,CC5EE,8BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDyEN,CCnFE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDgFN,CC1FE,kCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDuFN,CCjGE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD8FN,CCxGE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDqGN,CC/GE,6BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD4GN,CCtHE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDmHN,CC7HE,4BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCD6HN,CCpIE,8BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDoIN,CC3IE,6BACE,yBAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCD2IN,CClJE,8BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDkJN,CCzJE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDsJN,CE3JE,4BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwJN,CEnKE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgKN,CE3KE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwKN,CEnLE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgLN,CE3LE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwLN,CEnME,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgMN,CE3ME,mCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwMN,CEnNE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgNN,CE3NE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwNN,CEnOE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgON,CE3OE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwON,CEnPE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFmPN,CE3PE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCF2PN,CEnQE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFmQN,CE3QE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCF2QN,CEnRE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgRN,CE3RE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwRN,CEnSE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCAAA,CAKA,4BF4RN,CE5SE,kCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCAAA,CAKA,4BFqSN,CEtRE,sEACE,4BFyRJ,CE1RE,+DACE,4BF6RJ,CE9RE,iEACE,4BFiSJ,CElSE,gEACE,4BFqSJ,CEtSE,iEACE,4BFySJ,CEhSA,8BACE,mDAAA,CACA,4DAAA,CACA,0DAAA,CACA,oDAAA,CACA,2DAAA,CAGA,4BFiSF,CE9RE,yCACE,+BFgSJ,CE7RI,kDAEE,0CAAA,CACA,sCAAA,CAFA,mCFiSN,CG7MI,mCD1EA,+CACE,8CF0RJ,CEvRI,qDACE,8CFyRN,CEpRE,iEACE,mCFsRJ,CACF,CGxNI,sCDvDA,uCACE,oCFkRJ,CACF,CEzQA,8BACE,kDAAA,CACA,4DAAA,CACA,wDAAA,CACA,oDAAA,CACA,6DAAA,CAGA,4BF0QF,CEvQE,yCACE,+BFyQJ,CEtQI,kDAEE,0CAAA,CACA,sCAAA,CAFA,mCF0QN,CEnQE,yCACE,6CFqQJ,CG9NI,0CDhCA,8CACE,gDFiQJ,CACF,CGnOI,0CDvBA,iFACE,6CF6PJ,CACF,CG3PI,sCDKA,uCACE,6CFyPJ,CACF","file":"palette.css"}

View File

@@ -49,10 +49,10 @@
{% endif %}
{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.618322db.min.css' | url }}">
{% if config.theme.palette %}
{% set palette = config.theme.palette %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.ab4e12ef.min.css' | url }}">
{% endif %}
{% include "partials/icons.html" %}
{% endblock %}
@@ -242,7 +242,7 @@
"search.result.term.missing": lang.t("search.result.term.missing"),
"select.version": lang.t("select.version")
},
"search": "assets/javascripts/workers/search.js" | url,
"search": "assets/javascripts/workers/search.7a47a382.min.js" | url,
"annotate": _.annotate or none,
"tags": _.tags or none,
"version": _.version or none
@@ -250,7 +250,7 @@
</script>
{% endblock %}
{% block scripts %}
<script src="{{ 'assets/javascripts/bundle.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.e71a0d61.min.js' | url }}"></script>
{% for script in config.extra_javascript %}
{{ script | script_tag }}
{% endfor %}

View File

@@ -188,7 +188,6 @@ nav:
- Customization: customization.md
- Conventions: conventions.md
- Browser support: browser-support.md
- Enterprise feedback: enterprise-support.md
- Philosophy: philosophy.md
- Alternatives: alternatives.md
- License: license.md

View File

@@ -74,8 +74,6 @@ Changelog = "https://squidfunk.github.io/mkdocs-material/changelog/"
Issues = "https://github.com/squidfunk/mkdocs-material/issues"
[project.entry-points."mkdocs.plugins"]
# The commented-out plugins are deprecated, as they turned out to be dead ends.
# If you fork Material for MkDocs, just uncomment these lines to re-enable them.
"material/blog" = "material.plugins.blog.plugin:BlogPlugin"
"material/group" = "material.plugins.group.plugin:GroupPlugin"
"material/info" = "material.plugins.info.plugin:InfoPlugin"
@@ -83,11 +81,11 @@ Issues = "https://github.com/squidfunk/mkdocs-material/issues"
"material/offline" = "material.plugins.offline.plugin:OfflinePlugin"
"material/optimize" = "material.plugins.optimize.plugin:OptimizePlugin"
"material/privacy" = "material.plugins.privacy.plugin:PrivacyPlugin"
# "material/projects" = "material.plugins.projects.plugin:ProjectsPlugin"
"material/projects" = "material.plugins.projects.plugin:ProjectsPlugin"
"material/search" = "material.plugins.search.plugin:SearchPlugin"
"material/social" = "material.plugins.social.plugin:SocialPlugin"
"material/tags" = "material.plugins.tags.plugin:TagsPlugin"
# "material/typeset" = "material.plugins.typeset.plugin:TypesetPlugin"
"material/typeset" = "material.plugins.typeset.plugin:TypesetPlugin"
[project.entry-points."mkdocs.themes"]
material = "material.templates"

View File

@@ -0,0 +1,155 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { getElement, getLocation } from "~/browser"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Feature flag
*/
export type Flag =
| "announce.dismiss" /* Dismissable announcement bar */
| "content.code.annotate" /* Code annotations */
| "content.code.copy" /* Code copy button */
| "content.code.select" /* Code selection */
| "content.footnote.tooltips" /* Footnote tooltips */
| "content.lazy" /* Lazy content elements */
| "content.tabs.link" /* Link content tabs */
| "content.tooltips" /* Tooltips */
| "header.autohide" /* Hide header */
| "navigation.expand" /* Automatic expansion */
| "navigation.indexes" /* Section pages */
| "navigation.instant" /* Instant navigation */
| "navigation.instant.prefetch" /* Instant navigation prefetching */
| "navigation.instant.progress" /* Instant navigation progress */
| "navigation.instant.preview" /* Instant preview */
| "navigation.sections" /* Section navigation */
| "navigation.tabs" /* Tabs navigation */
| "navigation.tabs.sticky" /* Tabs navigation (sticky) */
| "navigation.top" /* Back-to-top button */
| "navigation.tracking" /* Anchor tracking */
| "search.highlight" /* Search highlighting */
| "search.share" /* Search sharing */
| "search.suggest" /* Search suggestions */
| "toc.follow" /* Following table of contents */
| "toc.integrate" /* Integrated table of contents */
/* ------------------------------------------------------------------------- */
/**
* Translation
*/
export type Translation =
| "clipboard.copy" /* Copy to clipboard */
| "clipboard.copied" /* Copied to clipboard */
| "search.result.placeholder" /* Type to start searching */
| "search.result.none" /* No matching documents */
| "search.result.one" /* 1 matching document */
| "search.result.other" /* # matching documents */
| "search.result.more.one" /* 1 more on this page */
| "search.result.more.other" /* # more on this page */
| "search.result.term.missing" /* Missing */
| "select.version" /* Version selector */
/**
* Translations
*/
export type Translations =
Record<Translation, string>
/* ------------------------------------------------------------------------- */
/**
* Versioning
*/
export interface Versioning {
provider: "mike" /* Version provider */
default?: string | string[] /* Default version */
alias?: boolean /* Show alias */
}
/**
* Configuration
*/
export interface Config {
base: string /* Base URL */
features: Flag[] /* Feature flags */
translations: Translations /* Translations */
search: string /* Search worker URL */
annotate?: Record<string, string[]> /* Annotation mappings */
tags?: Record<string, string> /* Tags mapping */
version?: Versioning /* Versioning */
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Retrieve global configuration and make base URL absolute
*/
const script = getElement("#__config")
const config: Config = JSON.parse(script.textContent!)
config.base = `${new URL(config.base, getLocation())}`
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve global configuration
*
* @returns Global configuration
*/
export function configuration(): Config {
return config
}
/**
* Check whether a feature flag is enabled
*
* @param flag - Feature flag
*
* @returns Test result
*/
export function feature(flag: Flag): boolean {
return config.features.includes(flag)
}
/**
* Retrieve the translation for the given key
*
* @param key - Key to be translated
* @param value - Positional value, if any
*
* @returns Translation
*/
export function translation(
key: Translation, value?: string | number
): string {
return typeof value !== "undefined"
? config.translations[key].replace("#", value.toString())
: config.translations[key]
}

View File

@@ -20,41 +20,29 @@
* IN THE SOFTWARE.
*/
import { merge, switchMap } from "rxjs"
import {
getComponentElements,
mountIconSearch,
mountParallax,
mountSponsorship
} from "./components"
import { setupAnalytics } from "./integrations"
ReplaySubject,
Subject,
fromEvent
} from "rxjs"
/* ----------------------------------------------------------------------------
* Application
* Functions
* ------------------------------------------------------------------------- */
/* Set up extra analytics events */
setupAnalytics()
/**
* Watch document
*
* Documents are implemented as subjects, so all downstream observables are
* automatically updated when a new document is emitted.
*
* @returns Document subject
*/
export function watchDocument(): Subject<Document> {
const document$ = new ReplaySubject<Document>(1)
fromEvent(document, "DOMContentLoaded", { once: true })
.subscribe(() => document$.next(document))
/* Set up extra component observables */
const component$ = document$
.pipe(
switchMap(() => merge(
/* Icon search */
...getComponentElements("iconsearch")
.map(el => mountIconSearch(el)),
/* Parallax */
...getComponentElements("parallax")
.map(el => mountParallax(el)),
/* Sponsorship */
...getComponentElements("sponsorship")
.map(el => mountSponsorship(el))
))
)
/* Subscribe to all components */
component$.subscribe()
/* Return document */
return document$
}

View File

@@ -0,0 +1,6 @@
{
"rules": {
"jsdoc/require-jsdoc": "off",
"jsdoc/require-returns-check": "off"
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve all elements matching the query selector
*
* @template T - Element type
*
* @param selector - Query selector
* @param node - Node of reference
*
* @returns Elements
*/
export function getElements<T extends keyof HTMLElementTagNameMap>(
selector: T, node?: ParentNode
): HTMLElementTagNameMap[T][]
export function getElements<T extends HTMLElement>(
selector: string, node?: ParentNode
): T[]
export function getElements<T extends HTMLElement>(
selector: string, node: ParentNode = document
): T[] {
return Array.from(node.querySelectorAll<T>(selector))
}
/**
* Retrieve an element matching a query selector or throw a reference error
*
* Note that this function assumes that the element is present. If unsure if an
* element is existent, use the `getOptionalElement` function instead.
*
* @template T - Element type
*
* @param selector - Query selector
* @param node - Node of reference
*
* @returns Element
*/
export function getElement<T extends keyof HTMLElementTagNameMap>(
selector: T, node?: ParentNode
): HTMLElementTagNameMap[T]
export function getElement<T extends HTMLElement>(
selector: string, node?: ParentNode
): T
export function getElement<T extends HTMLElement>(
selector: string, node: ParentNode = document
): T {
const el = getOptionalElement<T>(selector, node)
if (typeof el === "undefined")
throw new ReferenceError(
`Missing element: expected "${selector}" to be present`
)
/* Return element */
return el
}
/* ------------------------------------------------------------------------- */
/**
* Retrieve an optional element matching the query selector
*
* @template T - Element type
*
* @param selector - Query selector
* @param node - Node of reference
*
* @returns Element or nothing
*/
export function getOptionalElement<T extends keyof HTMLElementTagNameMap>(
selector: T, node?: ParentNode
): HTMLElementTagNameMap[T] | undefined
export function getOptionalElement<T extends HTMLElement>(
selector: string, node?: ParentNode
): T | undefined
export function getOptionalElement<T extends HTMLElement>(
selector: string, node: ParentNode = document
): T | undefined {
return node.querySelector<T>(selector) || undefined
}
/**
* Retrieve the currently active element
*
* @returns Element or nothing
*/
export function getActiveElement(): HTMLElement | undefined {
return (
document.activeElement?.shadowRoot?.activeElement as HTMLElement ??
document.activeElement as HTMLElement ??
undefined
)
}

View File

@@ -22,75 +22,60 @@
import {
Observable,
combineLatest,
delay,
debounceTime,
distinctUntilChanged,
filter,
fromEvent,
map,
merge,
startWith,
withLatestFrom
shareReplay,
startWith
} from "rxjs"
import { watchElementFocus } from "~/browser"
import { Component } from "../../_"
import { getActiveElement } from "../_"
/* ----------------------------------------------------------------------------
* Types
* Data
* ------------------------------------------------------------------------- */
/**
* Icon search query
* 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.
*/
export interface IconSearchQuery {
value: string /* Query value */
focus: boolean /* Query focus */
}
const observer$ = merge(
fromEvent(document.body, "focusin"),
fromEvent(document.body, "focusout")
)
.pipe(
debounceTime(1),
startWith(undefined),
map(() => getActiveElement() || document.body),
shareReplay(1)
)
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount icon search query
* Watch element focus
*
* @param el - Icon search query element
* @param el - Element
*
* @returns Icon search query component observable
* @returns Element focus observable
*/
export function mountIconSearchQuery(
el: HTMLInputElement
): Observable<Component<IconSearchQuery, HTMLInputElement>> {
/* Intercept focus and input events */
const focus$ = watchElementFocus(el)
const value$ = merge(
fromEvent(el, "keyup"),
fromEvent(el, "focus").pipe(delay(1))
)
export function watchElementFocus(
el: HTMLElement
): Observable<boolean> {
return observer$
.pipe(
map(() => el.value),
startWith(el.value),
distinctUntilChanged(),
)
/* Log search on blur */
focus$
.pipe(
filter(active => !active),
withLatestFrom(value$)
)
.subscribe(([, value]) => {
const path = document.location.pathname
if (typeof ga === "function" && value.length)
ga("send", "pageview", `${path}?q=[icon]+${value}`)
})
/* Combine into single observable */
return combineLatest([value$, focus$])
.pipe(
map(([value, focus]) => ({ ref: el, value, focus })),
map(active => el.contains(active)),
distinctUntilChanged()
)
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
debounce,
defer,
fromEvent,
identity,
map,
merge,
startWith,
timer
} from "rxjs"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch element hover
*
* The second parameter allows to specify a timeout in milliseconds after which
* the hover state will be reset to `false`. This is useful for tooltips which
* should disappear after a certain amount of time, in order to allow the user
* to move the cursor from the host to the tooltip.
*
* @param el - Element
* @param timeout - Timeout
*
* @returns Element hover observable
*/
export function watchElementHover(
el: HTMLElement, timeout?: number
): Observable<boolean> {
return defer(() => merge(
fromEvent(el, "mouseenter").pipe(map(() => true)),
fromEvent(el, "mouseleave").pipe(map(() => false))
)
.pipe(
timeout ? debounce(active => timer(+!active * timeout)) : identity,
startWith(el.matches(":hover"))
)
)
}

View File

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

View File

@@ -0,0 +1,125 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
animationFrameScheduler,
auditTime,
fromEvent,
map,
merge,
startWith
} from "rxjs"
import { watchElementSize } from "../../size"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Element offset
*/
export interface ElementOffset {
x: number /* Horizontal offset */
y: number /* Vertical offset */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve element offset
*
* @param el - Element
*
* @returns Element offset
*/
export function getElementOffset(
el: HTMLElement
): ElementOffset {
return {
x: el.offsetLeft,
y: el.offsetTop
}
}
/**
* Retrieve absolute element offset
*
* @param el - Element
*
* @returns Element offset
*/
export function getElementOffsetAbsolute(
el: HTMLElement
): ElementOffset {
const rect = el.getBoundingClientRect()
return {
x: rect.x + window.scrollX,
y: rect.y + window.scrollY
}
}
/* ------------------------------------------------------------------------- */
/**
* Watch element offset
*
* @param el - Element
*
* @returns Element offset observable
*/
export function watchElementOffset(
el: HTMLElement
): Observable<ElementOffset> {
return merge(
fromEvent(window, "load"),
fromEvent(window, "resize")
)
.pipe(
auditTime(0, animationFrameScheduler),
map(() => getElementOffset(el)),
startWith(getElementOffset(el))
)
}
/**
* Watch absolute element offset
*
* @param el - Element
*
* @returns Element offset observable
*/
export function watchElementOffsetAbsolute(
el: HTMLElement
): Observable<ElementOffset> {
return merge(
watchElementOffset(el),
watchElementSize(document.body) // @todo find a better way for this
)
.pipe(
map(() => getElementOffsetAbsolute(el)),
startWith(getElementOffsetAbsolute(el))
)
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
animationFrameScheduler,
auditTime,
fromEvent,
map,
merge,
startWith
} from "rxjs"
import { ElementOffset } from "../_"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve element content offset (= scroll offset)
*
* @param el - Element
*
* @returns Element content offset
*/
export function getElementContentOffset(
el: HTMLElement
): ElementOffset {
return {
x: el.scrollLeft,
y: el.scrollTop
}
}
/* ------------------------------------------------------------------------- */
/**
* Watch element content offset
*
* @param el - Element
*
* @returns Element content offset observable
*/
export function watchElementContentOffset(
el: HTMLElement
): Observable<ElementOffset> {
return merge(
fromEvent(el, "scroll"),
fromEvent(window, "scroll"),
fromEvent(window, "resize")
)
.pipe(
auditTime(0, animationFrameScheduler),
map(() => getElementContentOffset(el)),
startWith(getElementContentOffset(el))
)
}

View File

@@ -21,5 +21,4 @@
*/
export * from "./_"
export * from "./query"
export * from "./result"
export * from "./content"

View File

@@ -0,0 +1,159 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
NEVER,
Observable,
Subject,
defer,
filter,
finalize,
map,
merge,
of,
shareReplay,
startWith,
switchMap,
tap
} from "rxjs"
import { watchScript } from "../../../script"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Element offset
*/
export interface ElementSize {
width: number /* Element width */
height: number /* Element height */
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Resize observer entry subject
*/
const entry$ = new Subject<ResizeObserverEntry>()
/**
* Resize observer observable
*
* This observable will create a `ResizeObserver` on the first subscription
* and will automatically terminate it when there are no more subscribers.
* 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(() => (
typeof ResizeObserver === "undefined"
? watchScript("https://unpkg.com/resize-observer-polyfill")
: of(undefined)
))
.pipe(
map(() => new ResizeObserver(entries => (
entries.forEach(entry => entry$.next(entry))
))),
switchMap(observer => merge(NEVER, of(observer)).pipe(
finalize(() => observer.disconnect())
)),
shareReplay(1)
)
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve element size
*
* @param el - Element
*
* @returns Element size
*/
export function getElementSize(
el: HTMLElement
): ElementSize {
return {
width: el.offsetWidth,
height: el.offsetHeight
}
}
/* ------------------------------------------------------------------------- */
/**
* Watch element size
*
* This function returns an observable that subscribes to a single internal
* instance of `ResizeObserver` upon subscription, and emit resize events until
* termination. Note that this function should not be called with the same
* element twice, as the first unsubscription will terminate observation.
*
* Sadly, we can't use the `DOMRect` objects returned by the observer, because
* we need the emitted values to be consistent with `getElementSize`, which will
* return the used values (rounded) and not actual values (unrounded). Thus, we
* use the `offset*` properties. See the linked GitHub issue.
*
* @see https://bit.ly/3m0k3he - GitHub issue
*
* @param el - Element
*
* @returns Element size observable
*/
export function watchElementSize(
el: HTMLElement
): Observable<ElementSize> {
// Compute target element - since inline elements cannot be observed by the
// current `ResizeObserver` implementation as provided by browsers, we need
// to determine the first containing parent element and use that one as a
// target, while we always compute the actual size from the element.
let target = el
while (target.clientWidth === 0)
if (target.parentElement)
target = target.parentElement
else
break
// Observe target element and recompute element size on resize - as described
// above, the target element is not necessarily the element of interest
return observer$.pipe(
tap(observer => observer.observe(target)),
switchMap(observer => entry$.pipe(
filter(entry => entry.target === target),
finalize(() => observer.unobserve(target))
)),
map(() => getElementSize(el)),
startWith(getElementSize(el))
)
}

View File

@@ -0,0 +1,104 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { ElementSize } from "../_"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve element content size (= scroll width and height)
*
* @param el - Element
*
* @returns Element content size
*/
export function getElementContentSize(
el: HTMLElement
): ElementSize {
return {
width: el.scrollWidth,
height: el.scrollHeight
}
}
/**
* Retrieve the overflowing container of an element, if any
*
* @param el - Element
*
* @returns Overflowing container or nothing
*/
export function getElementContainer(
el: HTMLElement
): HTMLElement | undefined {
let parent = el.parentElement
while (parent)
if (
el.scrollWidth <= parent.scrollWidth &&
el.scrollHeight <= parent.scrollHeight
)
parent = (el = parent).parentElement
else
break
/* Return overflowing container */
return parent ? el : undefined
}
/**
* Retrieve all overflowing containers of an element, if any
*
* Note that this function has a slightly different behavior, so we should at
* some point consider refactoring how overflowing containers are handled.
*
* @param el - Element
*
* @returns Overflowing containers
*/
export function getElementContainers(
el: HTMLElement
): HTMLElement[] {
const containers: HTMLElement[] = []
// Walk up the DOM tree until we find an overflowing container
let parent = el.parentElement
while (parent) {
if (
el.clientWidth > parent.clientWidth ||
el.clientHeight > parent.clientHeight
)
containers.push(parent)
// Continue with parent element
parent = (el = parent).parentElement
}
// If the page is short, the body might not be overflowing and there might be
// no other containers, which is why we need to make sure the body is present
if (containers.length === 0)
containers.push(document.documentElement)
// Return overflowing containers
return containers
}

View File

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

View File

@@ -0,0 +1,131 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
NEVER,
Observable,
Subject,
defer,
distinctUntilChanged,
filter,
finalize,
map,
merge,
of,
shareReplay,
switchMap,
tap
} from "rxjs"
import {
getElementContentSize,
getElementSize,
watchElementContentOffset
} from "~/browser"
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Intersection observer entry subject
*/
const entry$ = new Subject<IntersectionObserverEntry>()
/**
* Intersection observer observable
*
* This observable will create an `IntersectionObserver` on first subscription
* and will automatically terminate it when there are no more subscribers.
*
* @see https://bit.ly/3iIYfEm - Google Groups on performance
*/
const observer$ = defer(() => of(
new IntersectionObserver(entries => {
for (const entry of entries)
entry$.next(entry)
}, {
threshold: 0
})
))
.pipe(
switchMap(observer => merge(NEVER, of(observer))
.pipe(
finalize(() => observer.disconnect())
)
),
shareReplay(1)
)
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch element visibility
*
* @param el - Element
*
* @returns Element visibility observable
*/
export function watchElementVisibility(
el: HTMLElement
): Observable<boolean> {
return observer$
.pipe(
tap(observer => observer.observe(el)),
switchMap(observer => entry$
.pipe(
filter(({ target }) => target === el),
finalize(() => observer.unobserve(el)),
map(({ isIntersecting }) => isIntersecting)
)
)
)
}
/**
* Watch element boundary
*
* This function returns an observable which emits whether the bottom content
* boundary (= scroll offset) of an element is within a certain threshold.
*
* @param el - Element
* @param threshold - Threshold
*
* @returns Element boundary observable
*/
export function watchElementBoundary(
el: HTMLElement, threshold = 16
): Observable<boolean> {
return watchElementContentOffset(el)
.pipe(
map(({ y }) => {
const visible = getElementSize(el)
const content = getElementContentSize(el)
return y >= (
content.height - visible.height - threshold
)
}),
distinctUntilChanged()
)
}

View File

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

View File

@@ -0,0 +1,148 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
EMPTY,
Observable,
filter,
fromEvent,
map,
merge,
share,
startWith,
switchMap
} from "rxjs"
import { getActiveElement } from "../element"
import { getToggle } from "../toggle"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Keyboard mode
*/
export type KeyboardMode =
| "global" /* Global */
| "search" /* Search is open */
/* ------------------------------------------------------------------------- */
/**
* Keyboard
*/
export interface Keyboard {
mode: KeyboardMode /* Keyboard mode */
type: string /* Key type */
claim(): void /* Key claim */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Check whether an element may receive keyboard input
*
* @param el - Element
* @param type - Key type
*
* @returns Test result
*/
function isSusceptibleToKeyboard(
el: HTMLElement, type: string
): boolean {
switch (el.constructor) {
/* Input elements */
case HTMLInputElement:
/* @ts-expect-error - omit unnecessary type cast */
if (el.type === "radio")
return /^Arrow/.test(type)
else
return true
/* Select element and textarea */
case HTMLSelectElement:
case HTMLTextAreaElement:
return true
/* Everything else */
default:
return el.isContentEditable
}
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch composition events
*
* @returns Composition observable
*/
export function watchComposition(): Observable<boolean> {
return merge(
fromEvent(window, "compositionstart").pipe(map(() => true)),
fromEvent(window, "compositionend").pipe(map(() => false))
)
.pipe(
startWith(false)
)
}
/**
* Watch keyboard
*
* @returns Keyboard observable
*/
export function watchKeyboard(): Observable<Keyboard> {
const keyboard$ = fromEvent<KeyboardEvent>(window, "keydown")
.pipe(
filter(ev => !(ev.metaKey || ev.ctrlKey)),
map(ev => ({
mode: getToggle("search") ? "search" : "global",
type: ev.key,
claim() {
ev.preventDefault()
ev.stopPropagation()
}
} as Keyboard)),
filter(({ mode, type }) => {
if (mode === "global") {
const active = getActiveElement()
if (typeof active !== "undefined")
return !isSusceptibleToKeyboard(active, type)
}
return true
}),
share()
)
/* Don't emit during composition events - see https://bit.ly/3te3Wl8 */
return watchComposition()
.pipe(
switchMap(active => !active ? keyboard$ : EMPTY)
)
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { Subject } from "rxjs"
import { feature } from "~/_"
import { h } from "~/utilities"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve location
*
* This function returns a `URL` object (and not `Location`) to normalize the
* typings across the application. Furthermore, locations need to be tracked
* without setting them and `Location` is a singleton which represents the
* current location.
*
* @returns URL
*/
export function getLocation(): URL {
return new URL(location.href)
}
/**
* Set location
*
* If instant navigation is enabled, this function creates a temporary anchor
* element, sets the `href` attribute, appends it to the body, clicks it, and
* then removes it again. The event will bubble up the DOM and be intercepted
* by the instant navigation event handlers.
*
* Note that we must append and remove the anchor element, or the event will
* not bubble up the DOM, making it impossible to intercept it.
*
* @param url - URL to navigate to
* @param navigate - Force navigation
*/
export function setLocation(
url: URL | HTMLLinkElement, navigate = false
): void {
if (feature("navigation.instant") && !navigate) {
const el = h("a", { href: url.href })
document.body.appendChild(el)
el.click()
el.remove()
// If we're not using instant navigation, and the page should not be reloaded
// just instruct the browser to navigate to the given URL
} else {
location.href = url.href
}
}
/* ------------------------------------------------------------------------- */
/**
* Watch location
*
* @returns Location subject
*/
export function watchLocation(): Subject<URL> {
return new Subject<URL>()
}

View File

@@ -0,0 +1,104 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
filter,
fromEvent,
map,
merge,
shareReplay,
startWith
} from "rxjs"
import { getOptionalElement } from "~/browser"
import { h } from "~/utilities"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve location hash
*
* @returns Location hash
*/
export function getLocationHash(): string {
return location.hash.slice(1)
}
/**
* Set location hash
*
* Setting a new fragment identifier via `location.hash` will have no effect
* if the value doesn't change. When a new fragment identifier is set, we want
* the browser to target the respective element at all times, which is why we
* use this dirty little trick.
*
* @param hash - Location hash
*/
export function setLocationHash(hash: string): void {
const el = h("a", { href: hash })
el.addEventListener("click", ev => ev.stopPropagation())
el.click()
}
/* ------------------------------------------------------------------------- */
/**
* Watch location hash
*
* @param location$ - Location observable
*
* @returns Location hash observable
*/
export function watchLocationHash(
location$: Observable<URL>
): Observable<string> {
return merge(
fromEvent<HashChangeEvent>(window, "hashchange"),
location$
)
.pipe(
map(getLocationHash),
startWith(getLocationHash()),
filter(hash => hash.length > 0),
shareReplay(1)
)
}
/**
* Watch location target
*
* @param location$ - Location observable
*
* @returns Location target observable
*/
export function watchLocationTarget(
location$: Observable<URL>
): Observable<HTMLElement> {
return watchLocationHash(location$)
.pipe(
map(id => getOptionalElement(`[id="${id}"]`)!),
filter(el => typeof el !== "undefined")
)
}

View File

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

View File

@@ -0,0 +1,95 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
EMPTY,
Observable,
fromEvent,
fromEventPattern,
map,
merge,
startWith,
switchMap
} from "rxjs"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch media query
*
* Note that although `MediaQueryList.addListener` is deprecated we have to
* use it, because it's the only way to ensure proper downward compatibility.
*
* @see https://bit.ly/3dUBH2m - GitHub issue
*
* @param query - Media query
*
* @returns Media observable
*/
export function watchMedia(query: string): Observable<boolean> {
const media = matchMedia(query)
return fromEventPattern<boolean>(next => (
media.addListener(() => next(media.matches))
))
.pipe(
startWith(media.matches)
)
}
/**
* Watch print mode
*
* @returns Print observable
*/
export function watchPrint(): Observable<boolean> {
const media = matchMedia("print")
return merge(
fromEvent(window, "beforeprint").pipe(map(() => true)),
fromEvent(window, "afterprint").pipe(map(() => false))
)
.pipe(
startWith(media.matches)
)
}
/* ------------------------------------------------------------------------- */
/**
* Toggle an observable with a media observable
*
* @template T - Data type
*
* @param query$ - Media observable
* @param factory - Observable factory
*
* @returns Toggled observable
*/
export function at<T>(
query$: Observable<boolean>, factory: () => Observable<T>
): Observable<T> {
return query$
.pipe(
switchMap(active => active ? factory() : EMPTY)
)
}

View File

@@ -0,0 +1,179 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
Subject,
map,
shareReplay,
switchMap
} from "rxjs"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Options
*/
interface Options {
progress$?: Subject<number> // Progress subject
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Fetch the given URL
*
* This function returns an observable that emits the response as a blob and
* completes, or emits an error if the request failed. The caller can cancel
* the request by unsubscribing at any time, which will automatically abort
* the inflight request and complete the observable.
*
* Note that we use `XMLHTTPRequest` not because we're nostalgic, but because
* it's the only way to get progress events for downloads and also allow for
* cancellation of requests, as the official Fetch API does not support this
* yet, even though we're already in 2024.
*
* @param url - Request URL
* @param options - Options
*
* @returns Data observable
*/
export function request(
url: URL | string, options?: Options
): Observable<Blob> {
return new Observable<Blob>(observer => {
const req = new XMLHttpRequest()
req.open("GET", `${url}`)
req.responseType = "blob"
// Handle response
req.addEventListener("load", () => {
if (req.status >= 200 && req.status < 300) {
observer.next(req.response)
observer.complete()
// Every response that is not in the 2xx range is considered an error
} else {
observer.error(new Error(req.statusText))
}
})
// Handle network errors
req.addEventListener("error", () => {
observer.error(new Error("Network error"))
})
// Handle aborted requests
req.addEventListener("abort", () => {
observer.complete()
})
// Handle download progress
if (typeof options?.progress$ !== "undefined") {
req.addEventListener("progress", event => {
if (event.lengthComputable) {
options.progress$!.next((event.loaded / event.total) * 100)
// Hack: Chromium doesn't report the total number of bytes if content
// is compressed, so we need this fallback - see https://t.ly/ZXofI
} else {
const length = req.getResponseHeader("Content-Length") ?? 0
options.progress$!.next((event.loaded / +length) * 100)
}
})
// Immediately set progress to 5% to indicate that we're loading
options.progress$.next(5)
}
// Send request and automatically abort request upon unsubscription
req.send()
return () => req.abort()
})
}
/* ------------------------------------------------------------------------- */
/**
* Fetch JSON from the given URL
*
* @template T - Data type
*
* @param url - Request URL
* @param options - Options
*
* @returns Data observable
*/
export function requestJSON<T>(
url: URL | string, options?: Options
): Observable<T> {
return request(url, options)
.pipe(
switchMap(res => res.text()),
map(body => JSON.parse(body) as T),
shareReplay(1)
)
}
/**
* Fetch HTML from the given URL
*
* @param url - Request URL
* @param options - Options
*
* @returns Data observable
*/
export function requestHTML(
url: URL | string, options?: Options
): Observable<Document> {
const dom = new DOMParser()
return request(url, options)
.pipe(
switchMap(res => res.text()),
map(res => dom.parseFromString(res, "text/html")),
shareReplay(1)
)
}
/**
* Fetch XML from the given URL
*
* @param url - Request URL
* @param options - Options
*
* @returns Data observable
*/
export function requestXML(
url: URL | string, options?: Options
): Observable<Document> {
const dom = new DOMParser()
return request(url, options)
.pipe(
switchMap(res => res.text()),
map(res => dom.parseFromString(res, "text/xml")),
shareReplay(1)
)
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
defer,
finalize,
fromEvent,
map,
merge,
switchMap,
take,
throwError
} from "rxjs"
import { h } from "~/utilities"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Create and load a `script` element
*
* This function returns an observable that will emit when the script was
* successfully loaded, or throw an error if it wasn't.
*
* @param src - Script URL
*
* @returns Script observable
*/
export function watchScript(src: string): Observable<void> {
const script = h("script", { src })
return defer(() => {
document.head.appendChild(script)
return merge(
fromEvent(script, "load"),
fromEvent(script, "error")
.pipe(
switchMap(() => (
throwError(() => new ReferenceError(`Invalid script: ${src}`))
))
)
)
.pipe(
map(() => undefined),
finalize(() => document.head.removeChild(script)),
take(1)
)
})
}

View File

@@ -0,0 +1,102 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
fromEvent,
map,
startWith
} from "rxjs"
import { getElement } from "../element"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Toggle
*/
export type Toggle =
| "drawer" /* Toggle for drawer */
| "search" /* Toggle for search */
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Toggle map
*/
const toggles: Record<Toggle, HTMLInputElement> = {
drawer: getElement("[data-md-toggle=drawer]"),
search: getElement("[data-md-toggle=search]")
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve the value of a toggle
*
* @param name - Toggle
*
* @returns Toggle value
*/
export function getToggle(name: Toggle): boolean {
return toggles[name].checked
}
/**
* Set toggle
*
* Simulating a click event seems to be the most cross-browser compatible way
* of changing the value while also emitting a `change` event. Before, Material
* used `CustomEvent` to programmatically change the value of a toggle, but this
* is a much simpler and cleaner solution which doesn't require a polyfill.
*
* @param name - Toggle
* @param value - Toggle value
*/
export function setToggle(name: Toggle, value: boolean): void {
if (toggles[name].checked !== value)
toggles[name].click()
}
/* ------------------------------------------------------------------------- */
/**
* Watch toggle
*
* @param name - Toggle
*
* @returns Toggle value observable
*/
export function watchToggle(name: Toggle): Observable<boolean> {
const el = toggles[name]
return fromEvent(el, "change")
.pipe(
map(() => el.checked),
startWith(el.checked)
)
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
combineLatest,
map,
shareReplay
} from "rxjs"
import {
ViewportOffset,
watchViewportOffset
} from "../offset"
import {
ViewportSize,
watchViewportSize
} from "../size"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Viewport
*/
export interface Viewport {
offset: ViewportOffset /* Viewport offset */
size: ViewportSize /* Viewport size */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch viewport
*
* @returns Viewport observable
*/
export function watchViewport(): Observable<Viewport> {
return combineLatest([
watchViewportOffset(),
watchViewportSize()
])
.pipe(
map(([offset, size]) => ({ offset, size })),
shareReplay(1)
)
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
combineLatest,
distinctUntilKeyChanged,
map
} from "rxjs"
import { Header } from "~/components"
import { getElementOffset } from "../../element"
import { Viewport } from "../_"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch viewport relative to element
*
* @param el - Element
* @param options - Options
*
* @returns Viewport observable
*/
export function watchViewportAt(
el: HTMLElement, { viewport$, header$ }: WatchOptions
): Observable<Viewport> {
const size$ = viewport$
.pipe(
distinctUntilKeyChanged("size")
)
/* Compute element offset */
const offset$ = combineLatest([size$, header$])
.pipe(
map(() => getElementOffset(el))
)
/* Compute relative viewport, return hot observable */
return combineLatest([header$, viewport$, offset$])
.pipe(
map(([{ height }, { offset, size }, { x, y }]) => ({
offset: {
x: offset.x - x,
y: offset.y - y + height
},
size
}))
)
}

View File

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

View File

@@ -0,0 +1,78 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
fromEvent,
map,
merge,
startWith
} from "rxjs"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Viewport offset
*/
export interface ViewportOffset {
x: number /* Horizontal offset */
y: number /* Vertical offset */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve viewport offset
*
* On iOS Safari, viewport offset can be negative due to overflow scrolling.
* As this may induce strange behaviors downstream, we'll just limit it to 0.
*
* @returns Viewport offset
*/
export function getViewportOffset(): ViewportOffset {
return {
x: Math.max(0, scrollX),
y: Math.max(0, scrollY)
}
}
/* ------------------------------------------------------------------------- */
/**
* Watch viewport offset
*
* @returns Viewport offset observable
*/
export function watchViewportOffset(): Observable<ViewportOffset> {
return merge(
fromEvent(window, "scroll", { passive: true }),
fromEvent(window, "resize", { passive: true })
)
.pipe(
map(getViewportOffset),
startWith(getViewportOffset())
)
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
fromEvent,
map,
startWith
} from "rxjs"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Viewport size
*/
export interface ViewportSize {
width: number /* Viewport width */
height: number /* Viewport height */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve viewport size
*
* @returns Viewport size
*/
export function getViewportSize(): ViewportSize {
return {
width: innerWidth,
height: innerHeight
}
}
/* ------------------------------------------------------------------------- */
/**
* Watch viewport size
*
* @returns Viewport size observable
*/
export function watchViewportSize(): Observable<ViewportSize> {
return fromEvent(window, "resize", { passive: true })
.pipe(
map(getViewportSize),
startWith(getViewportSize())
)
}

View File

@@ -0,0 +1,112 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
Subject,
endWith,
fromEvent,
ignoreElements,
mergeWith,
share,
takeUntil
} from "rxjs"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Worker message
*/
export interface WorkerMessage {
type: unknown /* Message type */
data?: unknown /* Message data */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Create an observable for receiving from a web worker
*
* @template T - Data type
*
* @param worker - Web worker
*
* @returns Message observable
*/
function recv<T>(worker: Worker): Observable<T> {
return fromEvent<MessageEvent<T>, T>(worker, "message", ev => ev.data)
}
/**
* Create a subject for sending to a web worker
*
* @template T - Data type
*
* @param worker - Web worker
*
* @returns Message subject
*/
function send<T>(worker: Worker): Subject<T> {
const send$ = new Subject<T>()
send$.subscribe(data => worker.postMessage(data))
/* Return message subject */
return send$
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Create a bidirectional communication channel to a web worker
*
* @template T - Data type
*
* @param url - Worker URL
* @param worker - Worker
*
* @returns Worker subject
*/
export function watchWorker<T extends WorkerMessage>(
url: string, worker = new Worker(url)
): Subject<T> {
const recv$ = recv<T>(worker)
const send$ = send<T>(worker)
/* Create worker subject and forward messages */
const worker$ = new Subject<T>()
worker$.subscribe(send$)
/* Return worker subject */
const done$ = send$.pipe(ignoreElements(), endWith(true))
return worker$
.pipe(
ignoreElements(),
mergeWith(recv$.pipe(takeUntil(done$))),
share()
) as Subject<T>
}

View File

@@ -0,0 +1,326 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import "focus-visible"
import {
EMPTY,
NEVER,
Observable,
Subject,
defer,
delay,
filter,
map,
merge,
mergeWith,
shareReplay,
switchMap
} from "rxjs"
import { configuration, feature } from "./_"
import {
at,
getActiveElement,
getOptionalElement,
requestJSON,
setLocation,
setToggle,
watchDocument,
watchKeyboard,
watchLocation,
watchLocationTarget,
watchMedia,
watchPrint,
watchScript,
watchViewport
} from "./browser"
import {
getComponentElement,
getComponentElements,
mountAnnounce,
mountBackToTop,
mountConsent,
mountContent,
mountDialog,
mountHeader,
mountHeaderTitle,
mountPalette,
mountProgress,
mountSearch,
mountSearchHiglight,
mountSidebar,
mountSource,
mountTableOfContents,
mountTabs,
watchHeader,
watchMain
} from "./components"
import {
SearchIndex,
fetchSitemap,
setupAlternate,
setupClipboardJS,
setupInstantNavigation,
setupVersionSelector
} from "./integrations"
import {
patchEllipsis,
patchIndeterminate,
patchScrollfix,
patchScrolllock
} from "./patches"
import "./polyfills"
/* ----------------------------------------------------------------------------
* Functions - @todo refactor
* ------------------------------------------------------------------------- */
/**
* Fetch search index
*
* @returns Search index observable
*/
function fetchSearchIndex(): Observable<SearchIndex> {
if (location.protocol === "file:") {
return watchScript(
`${new URL("search/search_index.js", config.base)}`
)
.pipe(
// @ts-ignore - @todo fix typings
map(() => __index),
shareReplay(1)
)
} else {
return requestJSON<SearchIndex>(
new URL("search/search_index.json", config.base)
)
}
}
/* ----------------------------------------------------------------------------
* Application
* ------------------------------------------------------------------------- */
/* Yay, JavaScript is available */
document.documentElement.classList.remove("no-js")
document.documentElement.classList.add("js")
/* Set up navigation observables and subjects */
const document$ = watchDocument()
const location$ = watchLocation()
const target$ = watchLocationTarget(location$)
const keyboard$ = watchKeyboard()
/* Set up media observables */
const viewport$ = watchViewport()
const tablet$ = watchMedia("(min-width: 60em)")
const screen$ = watchMedia("(min-width: 76.25em)")
const print$ = watchPrint()
/* Retrieve search index, if search is enabled */
const config = configuration()
const index$ = document.forms.namedItem("search")
? fetchSearchIndex()
: NEVER
/* Set up Clipboard.js integration */
const alert$ = new Subject<string>()
setupClipboardJS({ alert$ })
/* Set up language selector */
setupAlternate({ document$ })
/* Set up progress indicator */
const progress$ = new Subject<number>()
/* Set up sitemap for instant navigation and previews */
const sitemap$ = fetchSitemap(config.base)
/* Set up instant navigation, if enabled */
if (feature("navigation.instant"))
setupInstantNavigation({ sitemap$, location$, viewport$, progress$ })
.subscribe(document$)
/* Set up version selector */
if (config.version?.provider === "mike")
setupVersionSelector({ document$ })
/* Always close drawer and search on navigation */
merge(location$, target$)
.pipe(
delay(125)
)
.subscribe(() => {
setToggle("drawer", false)
setToggle("search", false)
})
/* Set up global keyboard handlers */
keyboard$
.pipe(
filter(({ mode }) => mode === "global")
)
.subscribe(key => {
switch (key.type) {
/* Go to previous page */
case "p":
case ",":
const prev = getOptionalElement<HTMLLinkElement>("link[rel=prev]")
if (typeof prev !== "undefined")
setLocation(prev)
break
/* Go to next page */
case "n":
case ".":
const next = getOptionalElement<HTMLLinkElement>("link[rel=next]")
if (typeof next !== "undefined")
setLocation(next)
break
/* Expand navigation, see https://bit.ly/3ZjG5io */
case "Enter":
const active = getActiveElement()
if (active instanceof HTMLLabelElement)
active.click()
}
})
/* Set up patches */
patchEllipsis({ viewport$, document$ })
patchIndeterminate({ document$, tablet$ })
patchScrollfix({ document$ })
patchScrolllock({ viewport$, tablet$ })
/* Set up header and main area observable */
const header$ = watchHeader(getComponentElement("header"), { viewport$ })
const main$ = document$
.pipe(
map(() => getComponentElement("main")),
switchMap(el => watchMain(el, { viewport$, header$ })),
shareReplay(1)
)
/* Set up control component observables */
const control$ = merge(
/* Consent */
...getComponentElements("consent")
.map(el => mountConsent(el, { target$ })),
/* Dialog */
...getComponentElements("dialog")
.map(el => mountDialog(el, { alert$ })),
/* Color palette */
...getComponentElements("palette")
.map(el => mountPalette(el)),
/* Progress bar */
...getComponentElements("progress")
.map(el => mountProgress(el, { progress$ })),
/* Search */
...getComponentElements("search")
.map(el => mountSearch(el, { index$, keyboard$ })),
/* Repository information */
...getComponentElements("source")
.map(el => mountSource(el))
)
/* Set up content component observables */
const content$ = defer(() => merge(
/* Announcement bar */
...getComponentElements("announce")
.map(el => mountAnnounce(el)),
/* Content */
...getComponentElements("content")
.map(el => mountContent(el, { sitemap$, viewport$, target$, print$ })),
/* Search highlighting */
...getComponentElements("content")
.map(el => feature("search.highlight")
? mountSearchHiglight(el, { index$, location$ })
: EMPTY
),
/* Header */
...getComponentElements("header")
.map(el => mountHeader(el, { viewport$, header$, main$ })),
/* Header title */
...getComponentElements("header-title")
.map(el => mountHeaderTitle(el, { viewport$, header$ })),
/* Sidebar */
...getComponentElements("sidebar")
.map(el => el.getAttribute("data-md-type") === "navigation"
? at(screen$, () => mountSidebar(el, { viewport$, header$, main$ }))
: at(tablet$, () => mountSidebar(el, { viewport$, header$, main$ }))
),
/* Navigation tabs */
...getComponentElements("tabs")
.map(el => mountTabs(el, { viewport$, header$ })),
/* Table of contents */
...getComponentElements("toc")
.map(el => mountTableOfContents(el, {
viewport$, header$, main$, target$
})),
/* Back-to-top button */
...getComponentElements("top")
.map(el => mountBackToTop(el, { viewport$, header$, main$, target$ }))
))
/* Set up component observables */
const component$ = document$
.pipe(
switchMap(() => content$),
mergeWith(control$),
shareReplay(1)
)
/* Subscribe to all components */
component$.subscribe()
/* ----------------------------------------------------------------------------
* Exports
* ------------------------------------------------------------------------- */
window.document$ = document$ /* Document observable */
window.location$ = location$ /* Location subject */
window.target$ = target$ /* Location target observable */
window.keyboard$ = keyboard$ /* Keyboard observable */
window.viewport$ = viewport$ /* Viewport observable */
window.tablet$ = tablet$ /* Media tablet observable */
window.screen$ = screen$ /* Media screen observable */
window.print$ = print$ /* Media print observable */
window.alert$ = alert$ /* Alert subject */
window.progress$ = progress$ /* Progress indicator subject */
window.component$ = component$ /* Component observable */

View File

@@ -30,15 +30,29 @@ import { getElement, getElements } from "~/browser"
* Component type
*/
export type ComponentType =
| "hero" /* Hero */
| "iconsearch" /* Icon search */
| "iconsearch-query" /* Icon search input */
| "iconsearch-result" /* Icon search results */
| "iconsearch-select" /* Icon search select */
| "parallax" /* Parallax container */
| "sponsorship" /* Sponsorship */
| "sponsorship-count" /* Sponsorship count */
| "sponsorship-total" /* Sponsorship total */
| "announce" /* Announcement bar */
| "container" /* Container */
| "consent" /* Consent */
| "content" /* Content */
| "dialog" /* Dialog */
| "header" /* Header */
| "header-title" /* Header title */
| "header-topic" /* Header topic */
| "main" /* Main area */
| "outdated" /* Version warning */
| "palette" /* Color palette */
| "progress" /* Progress indicator */
| "search" /* Search */
| "search-query" /* Search input */
| "search-result" /* Search results */
| "search-share" /* Search sharing */
| "search-suggest" /* Search suggestions */
| "sidebar" /* Sidebar */
| "skip" /* Skip link */
| "source" /* Repository information */
| "tabs" /* Navigation tabs */
| "toc" /* Table of contents */
| "top" /* Back-to-top button */
/**
* Component
@@ -62,15 +76,29 @@ export type Component<
* Component type map
*/
interface ComponentTypeMap {
"hero": HTMLElement /* Hero */
"iconsearch": HTMLElement /* Icon search */
"iconsearch-query": HTMLInputElement /* Icon search input */
"iconsearch-result": HTMLElement /* Icon search results */
"iconsearch-select": HTMLSelectElement
"parallax": HTMLElement /* Parallax container */
"sponsorship": HTMLElement /* Sponsorship */
"sponsorship-count": HTMLElement /* Sponsorship count */
"sponsorship-total": HTMLElement /* Sponsorship total */
"announce": HTMLElement /* Announcement bar */
"container": HTMLElement /* Container */
"consent": HTMLElement /* Consent */
"content": HTMLElement /* Content */
"dialog": HTMLElement /* Dialog */
"header": HTMLElement /* Header */
"header-title": HTMLElement /* Header title */
"header-topic": HTMLElement /* Header topic */
"main": HTMLElement /* Main area */
"outdated": HTMLElement /* Version warning */
"palette": HTMLElement /* Color palette */
"progress": HTMLElement /* Progress indicator */
"search": HTMLElement /* Search */
"search-query": HTMLInputElement /* Search input */
"search-result": HTMLElement /* Search results */
"search-share": HTMLAnchorElement /* Search sharing */
"search-suggest": HTMLElement /* Search suggestions */
"sidebar": HTMLElement /* Sidebar */
"skip": HTMLAnchorElement /* Skip link */
"source": HTMLAnchorElement /* Repository information */
"tabs": HTMLElement /* Navigation tabs */
"toc": HTMLElement /* Table of contents */
"top": HTMLAnchorElement /* Back-to-top button */
}
/* ----------------------------------------------------------------------------
@@ -90,7 +118,7 @@ interface ComponentTypeMap {
export function getComponentElement<T extends ComponentType>(
type: T, node: ParentNode = document
): ComponentTypeMap[T] {
return getElement(`[data-mdx-component=${type}]`, node)
return getElement(`[data-md-component=${type}]`, node)
}
/**
@@ -106,5 +134,5 @@ export function getComponentElement<T extends ComponentType>(
export function getComponentElements<T extends ComponentType>(
type: T, node: ParentNode = document
): ComponentTypeMap[T][] {
return getElements(`[data-mdx-component=${type}]`, node)
return getElements(`[data-md-component=${type}]`, node)
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
EMPTY,
Observable,
Subject,
defer,
finalize,
fromEvent,
map,
tap
} from "rxjs"
import { feature } from "~/_"
import { getElement } from "~/browser"
import { Component } from "../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Announcement bar
*/
export interface Announce {
hash: number /* Content hash */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch announcement bar
*
* @param el - Announcement bar element
*
* @returns Announcement bar observable
*/
export function watchAnnounce(
el: HTMLElement
): Observable<Announce> {
const button = getElement(".md-typeset > :first-child", el)
return fromEvent(button, "click", { once: true })
.pipe(
map(() => getElement(".md-typeset", el)),
map(content => ({ hash: __md_hash(content.innerHTML) }))
)
}
/**
* Mount announcement bar
*
* @param el - Announcement bar element
*
* @returns Announcement bar component observable
*/
export function mountAnnounce(
el: HTMLElement
): Observable<Component<Announce>> {
if (!feature("announce.dismiss") || !el.childElementCount)
return EMPTY
/* Support instant navigation - see https://t.ly/3FTme */
if (!el.hidden) {
const content = getElement(".md-typeset", el)
if (__md_hash(content.innerHTML) === __md_get("__announce"))
el.hidden = true
}
/* Mount component on subscription */
return defer(() => {
const push$ = new Subject<Announce>()
push$.subscribe(({ hash }) => {
el.hidden = true
/* Persist preference in local storage */
__md_set<number>("__announce", hash)
})
/* Create and return component */
return watchAnnounce(el)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
Subject,
finalize,
map,
tap
} from "rxjs"
import { Component } from "../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Consent
*/
export interface Consent {
hidden: boolean /* Consent is hidden */
}
/**
* Consent defaults
*/
export interface ConsentDefaults {
analytics?: boolean /* Consent for Analytics */
github?: boolean /* Consent for GitHub */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
target$: Observable<HTMLElement> /* Target observable */
}
/**
* Mount options
*/
interface MountOptions {
target$: Observable<HTMLElement> /* Target observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch consent
*
* @param el - Consent element
* @param options - Options
*
* @returns Consent observable
*/
export function watchConsent(
el: HTMLElement, { target$ }: WatchOptions
): Observable<Consent> {
return target$
.pipe(
map(target => ({ hidden: target !== el }))
)
}
/* ------------------------------------------------------------------------- */
/**
* Mount consent
*
* @param el - Consent element
* @param options - Options
*
* @returns Consent component observable
*/
export function mountConsent(
el: HTMLElement, options: MountOptions
): Observable<Component<Consent>> {
const internal$ = new Subject<Consent>()
internal$.subscribe(({ hidden }) => {
el.hidden = hidden
})
/* Create and return component */
return watchConsent(el, options)
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@@ -0,0 +1,172 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { Observable, merge } from "rxjs"
import { feature } from "~/_"
import { Viewport, getElements } from "~/browser"
import { Sitemap } from "~/integrations"
import { renderTooltip2 } from "~/templates"
import { Component } from "../../_"
import {
Tooltip,
mountInlineTooltip2,
mountTooltip2
} from "../../tooltip2"
import {
Annotation,
mountAnnotationBlock
} from "../annotation"
import {
CodeBlock,
mountCodeBlock
} from "../code"
import {
Details,
mountDetails
} from "../details"
import {
Link,
mountLink
} from "../link"
import {
Mermaid,
mountMermaid
} from "../mermaid"
import {
DataTable,
mountDataTable
} from "../table"
import {
ContentTabs,
mountContentTabs
} from "../tabs"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Content
*/
export type Content =
| Annotation
| CodeBlock
| ContentTabs
| DataTable
| Details
| Link
| Mermaid
| Tooltip
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Dependencies
*/
interface Dependencies {
sitemap$: Observable<Sitemap> // Sitemap observable
viewport$: Observable<Viewport> // Viewport observable
target$: Observable<HTMLElement> // Location target observable
print$: Observable<boolean> // Media print observable
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount content
*
* This function mounts all components that are found in the content of the
* actual article, including code blocks, data tables and details.
*
* @param el - Content element
* @param dependencies - Dependencies
*
* @returns Content component observable
*/
export function mountContent(
el: HTMLElement, dependencies: Dependencies
): Observable<Component<Content>> {
const { viewport$, target$, print$ } = dependencies
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$ })),
// Links
...getElements("a", el)
.map(child => mountLink(child, dependencies)),
// Mermaid diagrams
...getElements("pre.mermaid", el)
.map(child => mountMermaid(child)),
// Data tables
...getElements("table:not([class])", el)
.map(child => mountDataTable(child)),
// Details
...getElements("details", el)
.map(child => mountDetails(child, { target$, print$ })),
// Content tabs
...getElements("[data-tabs]", el)
.map(child => mountContentTabs(child, { viewport$, target$ })),
// Tooltips
...getElements("[title]:not([data-preview])", el)
.filter(() => feature("content.tooltips"))
.map(child => mountInlineTooltip2(child, { viewport$ })),
// Footnotes
...getElements(".footnote-ref", el)
.filter(() => feature("content.footnote.tooltips"))
// move into specific function! mountTooltip is a low level primitive...
.map(child => mountTooltip2(child, {
content$: new Observable<HTMLElement>(observer => {
// @ts-ignore
const hash = new URL(child.href).hash.slice(1)
const arr = Array.from(document.getElementById(hash)!
// @ts-ignore
// eslint-disable-next-line
.cloneNode(true).children) as any
const node = renderTooltip2(...arr)
observer.next(node)
// Append tooltip and remove on unsubscription
document.body.append(node)
return () => node.remove()
}),
viewport$
}))
)
}

View File

@@ -0,0 +1,272 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
Subject,
animationFrameScheduler,
auditTime,
combineLatest,
debounceTime,
defer,
delay,
endWith,
filter,
finalize,
fromEvent,
ignoreElements,
map,
merge,
switchMap,
take,
takeUntil,
tap,
throttleTime,
withLatestFrom
} from "rxjs"
import {
ElementOffset,
getActiveElement,
getElementSize,
watchElementContentOffset,
watchElementFocus,
watchElementOffset,
watchElementVisibility
} from "~/browser"
import { Component } from "../../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Annotation
*/
export interface Annotation {
active: boolean /* Annotation is active */
offset: ElementOffset /* Annotation offset */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
target$: Observable<HTMLElement> /* Location target observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch annotation
*
* @param el - Annotation element
* @param container - Containing element
*
* @returns Annotation observable
*/
export function watchAnnotation(
el: HTMLElement, container: HTMLElement
): Observable<Annotation> {
const offset$ = defer(() => combineLatest([
watchElementOffset(el),
watchElementContentOffset(container)
]))
.pipe(
map(([{ x, y }, scroll]): ElementOffset => {
const { width, height } = getElementSize(el)
return ({
x: x - scroll.x + width / 2,
y: y - scroll.y + height / 2
})
})
)
/* Actively watch annotation on focus */
return watchElementFocus(el)
.pipe(
switchMap(active => offset$
.pipe(
map(offset => ({ active, offset })),
take(+!active || Infinity)
)
)
)
}
/**
* Mount annotation
*
* @param el - Annotation element
* @param container - Containing element
* @param options - Options
*
* @returns Annotation component observable
*/
export function mountAnnotation(
el: HTMLElement, container: HTMLElement, { target$ }: MountOptions
): Observable<Component<Annotation>> {
const [tooltip, index] = Array.from(el.children)
/* Mount component on subscription */
return defer(() => {
const push$ = new Subject<Annotation>()
const done$ = push$.pipe(ignoreElements(), endWith(true))
push$.subscribe({
/* Handle emission */
next({ offset }) {
el.style.setProperty("--md-tooltip-x", `${offset.x}px`)
el.style.setProperty("--md-tooltip-y", `${offset.y}px`)
},
/* Handle complete */
complete() {
el.style.removeProperty("--md-tooltip-x")
el.style.removeProperty("--md-tooltip-y")
}
})
/* Start animation only when annotation is visible */
watchElementVisibility(el)
.pipe(
takeUntil(done$)
)
.subscribe(visible => {
el.toggleAttribute("data-md-visible", visible)
})
/* Toggle tooltip presence to mitigate empty lines when copying */
merge(
push$.pipe(filter(({ active }) => active)),
push$.pipe(debounceTime(250), filter(({ active }) => !active))
)
.subscribe({
/* Handle emission */
next({ active }) {
if (active)
el.prepend(tooltip)
else
tooltip.remove()
},
/* Handle complete */
complete() {
el.prepend(tooltip)
}
})
/* Toggle tooltip visibility */
push$
.pipe(
auditTime(16, animationFrameScheduler)
)
.subscribe(({ active }) => {
tooltip.classList.toggle("md-tooltip--active", active)
})
/* Track relative origin of tooltip */
push$
.pipe(
throttleTime(125, animationFrameScheduler),
filter(() => !!el.offsetParent),
map(() => el.offsetParent!.getBoundingClientRect()),
map(({ x }) => x)
)
.subscribe({
/* Handle emission */
next(origin) {
if (origin)
el.style.setProperty("--md-tooltip-0", `${-origin}px`)
else
el.style.removeProperty("--md-tooltip-0")
},
/* Handle complete */
complete() {
el.style.removeProperty("--md-tooltip-0")
}
})
/* Allow to copy link without scrolling to anchor */
fromEvent<MouseEvent>(index, "click")
.pipe(
takeUntil(done$),
filter(ev => !(ev.metaKey || ev.ctrlKey))
)
.subscribe(ev => {
ev.stopPropagation()
ev.preventDefault()
})
/* Allow to open link in new tab or blur on close */
fromEvent<MouseEvent>(index, "mousedown")
.pipe(
takeUntil(done$),
withLatestFrom(push$)
)
.subscribe(([ev, { active }]) => {
/* Open in new tab */
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey) {
ev.preventDefault()
/* Close annotation */
} else if (active) {
ev.preventDefault()
/* Focus parent annotation, if any */
const parent = el.parentElement!.closest(".md-annotation")
if (parent instanceof HTMLElement)
parent.focus()
else
getActiveElement()?.blur()
}
})
/* Open and focus annotation on location target */
target$
.pipe(
takeUntil(done$),
filter(target => target === tooltip),
delay(125)
)
.subscribe(() => el.focus())
/* Create and return component */
return watchAnnotation(el, container)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

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

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

View File

@@ -0,0 +1,232 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
EMPTY,
Observable,
Subject,
defer,
endWith,
finalize,
ignoreElements,
merge,
share,
takeUntil
} from "rxjs"
import { configuration } from "~/_"
import {
getElement,
getElements,
getOptionalElement
} from "~/browser"
import { renderAnnotation } from "~/templates"
import { Component } from "../../../_"
import {
Annotation,
mountAnnotation
} from "../_"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
target$: Observable<HTMLElement> /* Location target observable */
print$: Observable<boolean> /* Media print observable */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Find all annotation hosts in the containing element
*
* @param container - Containing element
*
* @returns Annotation hosts
*/
function findHosts(container: HTMLElement): HTMLElement[] {
const config = configuration()
if (container.tagName !== "CODE")
return [container]
/* Try to determine language of code block */
const selectors = [".c", ".c1", ".cm"]
if (config.annotate && typeof config.annotate === "object") {
const host = container.closest("[class|=language]")
if (host) {
/* Extract language from class name */
for (const value of Array.from(host.classList)) {
if (!value.startsWith("language-"))
continue
/* Obtain additional mappings, if any */
const [, language] = value.split("-")
if (language in config.annotate)
selectors.push(...config.annotate[language])
}
}
}
/* Retrieve and return annotation hosts */
return getElements(selectors.join(", "), container)
}
/**
* Find all annotation markers in the containing element
*
* @param container - Containing element
*
* @returns Annotation markers
*/
function findMarkers(container: HTMLElement): Text[] {
const markers: Text[] = []
for (const el of findHosts(container)) {
const nodes: Text[] = []
/* Find all text nodes in current element */
const it = document.createNodeIterator(el, NodeFilter.SHOW_TEXT)
for (let node = it.nextNode(); node; node = it.nextNode())
nodes.push(node as Text)
/* Find all markers in each text node */
for (let text of nodes) {
let match: RegExpExecArray | null
/* Split text at marker and add to list */
while ((match = /(\(\d+\))(!)?/.exec(text.textContent!))) {
const [, id, force] = match
if (typeof force === "undefined") {
const marker = text.splitText(match.index)
text = marker.splitText(id.length)
markers.push(marker)
/* Replace entire text with marker */
} else {
text.textContent = id
markers.push(text)
break
}
}
}
}
return markers
}
/**
* Swap the child nodes of two elements
*
* @param source - Source element
* @param target - Target element
*/
function swap(source: HTMLElement, target: HTMLElement): void {
target.append(...Array.from(source.childNodes))
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount annotation list
*
* This function analyzes the containing code block and checks for markers
* referring to elements in the given annotation list. If no markers are found,
* the list is left untouched. Otherwise, list elements are rendered as
* annotations inside the code block.
*
* @param el - Annotation list element
* @param container - Containing element
* @param options - Options
*
* @returns Annotation component observable
*/
export function mountAnnotationList(
el: HTMLElement, container: HTMLElement, { target$, print$ }: MountOptions
): Observable<Component<Annotation>> {
/* Compute prefix for tooltip anchors */
const parent = container.closest("[id]")
const prefix = parent?.id
/* Find and replace all markers with empty annotations */
const annotations = new Map<string, HTMLElement>()
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))
marker.replaceWith(annotations.get(id)!)
}
}
/* Keep list if there are no annotations to render */
if (annotations.size === 0)
return EMPTY
/* Mount component on subscription */
return defer(() => {
const push$ = new Subject()
const done$ = push$.pipe(ignoreElements(), endWith(true))
/* Retrieve container pairs for swapping */
const pairs: [HTMLElement, HTMLElement][] = []
for (const [id, annotation] of annotations)
pairs.push([
getElement(".md-typeset", annotation),
getElement(`:scope > li:nth-child(${id})`, el)
])
/* Handle print mode - see https://bit.ly/3rgPdpt */
print$.pipe(takeUntil(done$))
.subscribe(active => {
el.hidden = !active
/* Add class to discern list element */
el.classList.toggle("md-annotation-list", active)
/* Show annotations in code block or list (print) */
for (const [inner, child] of pairs)
if (!active)
swap(child, inner)
else
swap(inner, child)
})
/* Create and return component */
return merge(...[...annotations]
.map(([, annotation]) => (
mountAnnotation(annotation, container, { target$ })
))
)
.pipe(
finalize(() => push$.complete()),
share()
)
})
}

View File

@@ -0,0 +1,514 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import ClipboardJS from "clipboard"
import {
EMPTY,
Observable,
Subject,
asyncScheduler,
defer,
distinctUntilChanged,
distinctUntilKeyChanged,
filter,
finalize,
from,
fromEvent,
map,
merge,
mergeMap,
mergeWith,
observeOn,
scan,
share,
shareReplay,
startWith,
switchMap,
take,
takeLast,
takeUntil,
tap,
withLatestFrom
} from "rxjs"
import { feature } from "~/_"
import {
getElement,
getElementContentSize,
getElements,
getOptionalElement,
watchElementHover,
watchElementSize,
watchElementVisibility,
watchLocationHash
} from "~/browser"
import {
Tooltip,
mountInlineTooltip2
} from "~/components/tooltip2"
import {
renderClipboardButton,
renderCodeBlockNavigation,
renderSelectionButton
} from "~/templates"
import { Component } from "../../../_"
import {
Annotation,
mountAnnotationList
} from "../../annotation"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Code block overflow
*/
export interface Overflow {
scrollable: boolean /* Code block overflows */
}
/**
* Code block
*/
export type CodeBlock =
| Overflow
| Annotation
| Tooltip
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
target$: Observable<HTMLElement> /* Location target observable */
print$: Observable<boolean> /* Media print observable */
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Global sequence number for code blocks
*/
let sequence = 0
/**
* Shift-key observable - @todo consolidate with keyboard observable
*/
const shift$ = merge(
fromEvent(window, "keydown").pipe(map(() => true)),
merge(
fromEvent(window, "keyup"),
fromEvent(window, "contextmenu")
)
.pipe(map(() => false))
)
.pipe(
startWith(false),
shareReplay(1)
)
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Find list element directly following a code block
*
* @param el - Code 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
* ------------------------------------------------------------------------- */
/**
* Watch code block
*
* This function monitors size changes of the viewport, as well as switches of
* content tabs with embedded code blocks, as both may trigger overflow.
*
* @param el - Code block element
*
* @returns Code block observable
*/
export function watchCodeBlock(
el: HTMLElement
): Observable<Overflow> {
return watchElementSize(el)
.pipe(
map(({ width }) => {
const content = getElementContentSize(el)
return {
scrollable: content.width > width
}
}),
distinctUntilKeyChanged("scrollable")
)
}
/**
* Mount code block
*
* This function ensures that an overflowing code block is focusable through
* keyboard, so it can be scrolled without a mouse to improve on accessibility.
* Furthermore, if code annotations are enabled, they are mounted if and only
* if the code block is currently visible, e.g., not in a hidden content tab.
*
* Note that code blocks may be mounted eagerly or lazily. If they're mounted
* lazily (on first visibility), code annotation anchor links will not work,
* as they are evaluated on initial page load, and code annotations in general
* might feel a little bumpier.
*
* @param el - Code block element
* @param options - Options
*
* @returns Code block and annotation component observable
*/
export function mountCodeBlock(
el: HTMLElement, options: MountOptions
): Observable<Component<CodeBlock>> {
const { matches: hover } = matchMedia("(hover)")
/* Defer mounting of code block - see https://bit.ly/3vHVoVD */
const factory$ = defer(() => {
const push$ = new Subject<Overflow>()
const done$ = push$.pipe(takeLast(1))
push$.subscribe(({ scrollable }) => {
if (scrollable && hover)
el.setAttribute("tabindex", "0")
else
el.removeAttribute("tabindex")
})
// Code block sequence number
const buttons: HTMLElement[] = []
const parent = el.closest("pre")!
// Check if there's a parent element with an id, and use that id, otherwise
// generate a new one. This is necessary to allow for authors to define
// unique ids for code blocks - see https://t.ly/q7UEq
const unique = parent.closest("[id]")
const id = unique ? unique.id : sequence++
parent.id = `__code_${id}`
/* Handle code annotations and highlights */
const content$: Array<Observable<Component<CodeBlock>>> = []
const container = el.closest(".highlight")
if (container instanceof HTMLElement) {
const list = findList(container)
/* Mount code annotations, if enabled */
if (typeof list !== "undefined" && (
container.classList.contains("annotate") ||
feature("content.code.annotate")
)) {
const annotations$ = mountAnnotationList(list, el, options)
content$.push(
watchElementSize(container)
.pipe(
takeUntil(done$),
map(({ width, height }) => width && height),
distinctUntilChanged(),
switchMap(active => active ? annotations$ : EMPTY)
)
)
}
}
// @todo: refactor and move into separate component
/* Check if code block has line spans */
const spans = getElements(":scope > span[id]", el)
if (spans.length) {
el.classList.add("md-code__content")
/* Mount code selection */
if (el.closest(".select") || (
feature("content.code.select") && !el.closest(".no-select")
)) {
const base = +spans[0].id.split("-").pop()!
/* Mount tooltip, if enabled */
const button = renderSelectionButton()
buttons.push(button)
if (feature("content.tooltips"))
content$.push(mountInlineTooltip2(button, { viewport$ }))
/* Selection state */
const select$ = fromEvent(button, "click")
.pipe(
scan(active => !active, false),
tap(() => button.blur()),
share()
)
/* Toggle active selection state on button */
select$.subscribe(active => {
button.classList.toggle("md-code__button--active", active)
})
/* Observable that monitors hovering */
const hover$ = from(spans)
.pipe(
mergeMap(span => watchElementHover(span)
.pipe(
map(active => [span, active] as const)
)
)
)
/* Trigger hover selection state based on state */
select$
.pipe(
switchMap(active => active ? hover$ : EMPTY)
)
.subscribe(([span, active]) => {
// @todo: don't mutate, but wrap everything in selection elements
const highlight = getOptionalElement(".hll.select", span)
if (highlight && !active) {
highlight.replaceWith(...Array.from(highlight.childNodes))
} else if (!highlight && active) {
const hll = document.createElement("span")
hll.className = "hll select"
hll.append(...Array.from(span.childNodes).slice(1))
span.append(hll)
}
})
// @todo: use a single event handler and consolidate events
const click$ = from(spans)
.pipe(
mergeMap(span => fromEvent(span, "mousedown")
.pipe(
tap(ev => ev.preventDefault()),
map(() => span)
)
)
)
const range$ = select$
.pipe(
switchMap(active => active ? click$ : EMPTY),
withLatestFrom(shift$),
map(([span, shift]) => {
/* Determine focused line number */
const active = spans.indexOf(span) + base
if (shift === false) {
return [active, active] as const
/* Shift is pressed, so extend selection */
} else {
const range = getElements(".hll", el)
.map(line => spans.indexOf(line.parentElement!) + base)
// Hack: this is a side effect, but we need to remove all ranges
// or rendering might look weird.
window.getSelection()?.removeAllRanges()
/* Return range */
return [
Math.min(active, ...range),
Math.max(active, ...range)
] as const
}
})
)
// Currently, all mounted code blocks will receive this event. That#s
// not ideal, since we should handle this higher up the tree
const hash$ = watchLocationHash(EMPTY)
.pipe(
// @todo: make more resilient
filter(hash => hash.startsWith(`__codelineno-${id}-`))
)
hash$.subscribe(hash => {
const [, , line] = hash.split("-")
const range = line.split(":").map(value => +value - base + 1)
if (range.length === 1)
range.push(range[0])
// remove all existing, then set range...
for (const span of getElements(".hll:not(.select)", el)) {
span.replaceWith(...Array.from(span.childNodes))
}
// set new range @todo move this into one block
const selection = spans.slice(range[0] - 1, range[1])
for (const span of selection) {
const hll = document.createElement("span")
hll.className = "hll"
hll.append(...Array.from(span.childNodes).slice(1))
span.append(hll)
}
})
hash$.pipe(take(1), observeOn(asyncScheduler))
.subscribe(hash => {
if (hash.includes(":")) {
const anchor = document.getElementById(hash.split(":")[0])
if (anchor) {
// this is a hack - we will refactor anchor / targetting as one
// of the next big things when merging one of the next goals.
// we need to unify how offsets are computed for tooltips and
// make the whole experience smoother.
setTimeout(() => {
let tmp = anchor
let top = -(48 + 16)
while (tmp !== document.body) {
top += tmp.offsetTop
tmp = tmp.offsetParent as HTMLElement
}
window.scrollTo({ top })
}, 1)
}
}
})
/* Allow selection via anchor links */
const jump$ = from(
getElements("a[href^=\"#__codelineno\"]", container!)
)
.pipe(
mergeMap(anchor => fromEvent(anchor, "click")
.pipe(
tap(ev => ev.preventDefault()),
map(() => anchor as HTMLAnchorElement)
)
)
)
/* Determine next highlighted lines */
const next$ = jump$
.pipe(
takeUntil(done$),
withLatestFrom(shift$),
map(([anchor, shift]) => {
const target = getElement(`[id="${anchor.hash.slice(1)}"]`)
/* Determine focused line number */
const active = +target.parentElement!.id.split("-").pop()!
if (shift === false) {
return [active, active] as const
/* Shift is pressed, so extend selection */
} else {
const range = getElements(".hll", el)
.map(line => +line.parentElement!.id.split("-").pop()!)
/* Return range */
return [
Math.min(active, ...range),
Math.max(active, ...range)
] as const
}
})
)
// Push selection to URL
merge(range$, next$).subscribe(range => {
// @todo: improve resilience, so we're not dependent on class names
let hash = `#__codelineno-${id}-`
if (range[0] === range[1]) {
hash += range[0]
} else {
hash += `${range[0]}:${range[1]}`
}
history.replaceState({}, "", hash)
// @hack dispatch artificial hashchange event
window.dispatchEvent(new HashChangeEvent("hashchange", {
newURL: window.location.origin + window.location.pathname + hash,
oldURL: window.location.href
}))
})
}
}
/* Render button for Clipboard.js integration */
if (ClipboardJS.isSupported()) {
if (el.closest(".copy") || (
feature("content.code.copy") && !el.closest(".no-copy")
)) {
/* Mount tooltip, if enabled */
const button = renderClipboardButton(parent.id)
buttons.push(button)
if (feature("content.tooltips"))
content$.push(mountInlineTooltip2(button, { viewport$ }))
}
}
// @hack Render code navigation and buttons
if (buttons.length) {
const nav = renderCodeBlockNavigation()
nav.append(...buttons)
parent.insertBefore(nav, el)
}
/* Create and return component */
return watchCodeBlock(el)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state })),
mergeWith(merge(...content$).pipe(
takeUntil(done$)
)),
)
})
/* Mount code block lazily */
if (feature("content.lazy"))
return watchElementVisibility(el)
.pipe(
filter(visible => visible),
take(1),
switchMap(() => factory$)
)
/* Mount code block */
return factory$
}

View File

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

View File

@@ -0,0 +1,138 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
Subject,
defer,
filter,
finalize,
map,
merge,
tap
} from "rxjs"
import { Component } from "../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Details
*/
export interface Details {
action: "open" | "close" /* Details state */
reveal?: boolean /* Details is revealed */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
target$: Observable<HTMLElement> /* Location target observable */
print$: Observable<boolean> /* Media print observable */
}
/**
* Mount options
*/
interface MountOptions {
target$: Observable<HTMLElement> /* Location target observable */
print$: Observable<boolean> /* Media print observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch details
*
* @param el - Details element
* @param options - Options
*
* @returns Details observable
*/
export function watchDetails(
el: HTMLDetailsElement, { target$, print$ }: WatchOptions
): Observable<Details> {
let open = true
return merge(
/* Open and focus details on location target */
target$
.pipe(
map(target => target.closest("details:not([open])")!),
filter(details => el === details),
map(() => ({
action: "open", reveal: true
}) as Details)
),
/* Open details on print and close afterwards */
print$
.pipe(
filter(active => active || !open),
tap(() => open = el.open),
map(active => ({
action: active ? "open" : "close"
}) as Details)
)
)
}
/**
* Mount details
*
* This function ensures that `details` tags are opened on anchor jumps and
* prior to printing, so the whole content of the page is visible.
*
* @param el - Details element
* @param options - Options
*
* @returns Details component observable
*/
export function mountDetails(
el: HTMLDetailsElement, options: MountOptions
): Observable<Component<Details>> {
return defer(() => {
const push$ = new Subject<Details>()
push$.subscribe(({ action, reveal }) => {
el.toggleAttribute("open", action === "open")
if (reveal)
el.scrollIntoView()
})
/* Create and return component */
return watchDetails(el, options)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

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

View File

@@ -0,0 +1,241 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
EMPTY,
Observable,
combineLatest,
distinctUntilChanged,
filter,
map,
of,
switchMap,
zip
} from "rxjs"
import { feature } from "~/_"
import {
Viewport,
getElements,
getOptionalElement,
requestHTML,
watchElementFocus,
watchElementHover
} from "~/browser"
import { Sitemap } from "~/integrations"
import { renderTooltip2 } from "~/templates"
import { Component } from "../../_"
import { mountTooltip2 } from "../../tooltip2"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Link
*/
export interface Link {}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Dependencies
*/
interface Dependencies {
sitemap$: Observable<Sitemap> // Sitemap observable
viewport$: Observable<Viewport> // Viewport observable
target$: Observable<HTMLElement> // Location target observable
print$: Observable<boolean> // Media print observable
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Global sequence number for instant previews
*/
let sequence = 0
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Extract elements until next heading
*
* @param headline - Heading
*
* @returns Elements until next heading
*/
function extract(headline: HTMLElement): HTMLElement[] {
const newHeading = document.createElement("h3")
newHeading.innerHTML = headline.innerHTML
const els = [newHeading]
//
let nextElement = headline.nextElementSibling
while (nextElement && !(nextElement instanceof HTMLHeadingElement)) {
// @ts-expect-error - fix once instant previews are stable
els.push(nextElement as HTMLElement)
nextElement = nextElement.nextElementSibling
}
//
return els
}
/**
* Resolve relative URLs in the given document
*
* @todo deduplicate with resolution in instant navigation. This functoion also
* adds the ability to resolve from a specific base URL, which is essential for
* instant previews to work, so we should generalize this functionality the
* next time we work on instant navigation.
*
* @param document - Document
* @param base - Base URL
*
* @returns Document observable
*/
function resolve(
document: Document, base: URL | string
): Observable<Document> {
// Replace all links
for (const el of getElements("[href], [src]", document))
for (const key of ["href", "src"]) {
const value = el.getAttribute(key)
if (value && !/^(?:[a-z]+:)?\/\//i.test(value)) {
// @ts-expect-error - trick: self-assign to resolve URL
el[key] = new URL(el.getAttribute(key), base).toString()
break
}
}
// Ensure ids are free of collisions (e.g. content tabs)
for (const el of getElements("[name^=__], [for]", document))
for (const key of ["id", "for", "name"]) {
const value = el.getAttribute(key)
if (value) {
el.setAttribute(key, `${value}$preview_${sequence}`)
}
}
// Return document observable
sequence++
return of(document)
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount Link
*
* @param el - Link element
* @param dependencies - Depenendencies
*
* @returns Link component observable
*/
export function mountLink(
el: HTMLElement, dependencies: Dependencies
): Observable<Component<Link>> {
const { sitemap$ } = dependencies
if (!(el instanceof HTMLAnchorElement))
return EMPTY
//
if (!(
feature("navigation.instant.preview") ||
el.hasAttribute("data-preview")
))
return EMPTY
// Remove title, as it will overlay the instant preview, and we want to give
// instant previews precedence over titles see https://t.ly/o0_Rk
el.removeAttribute("title")
const active$ =
combineLatest([
watchElementFocus(el),
watchElementHover(el)
])
.pipe(
map(([focus, hover]) => focus || hover),
distinctUntilChanged(),
filter(active => active)
)
// @todo: this is taken from the handle function in instant loading - we
// should generalize this once instant loading becomes stable.
const elements$ = zip([sitemap$, active$]).pipe(
switchMap(([sitemap]) => {
const url = new URL(el.href)
url.search = url.hash = ""
//
if (!sitemap.has(`${url}`))
return EMPTY
//
return of(url)
}),
switchMap(url => requestHTML(url).pipe(
switchMap(doc => resolve(doc, url))
)),
switchMap(doc => {
const selector = el.hash
? `article [id="${el.hash.slice(1)}"]`
: "article h1"
//
const target = getOptionalElement(selector, doc)
if (typeof target === "undefined")
return EMPTY
//
return of(extract(target))
})
)
//
return elements$.pipe(
switchMap(els => {
const content$ = new Observable<HTMLElement>(observer => {
const node = renderTooltip2(...els)
observer.next(node)
//
document.body.append(node)
return () => node.remove()
})
//
return mountTooltip2(el, { content$, ...dependencies })
})
)
}

View File

@@ -0,0 +1,415 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
/* ----------------------------------------------------------------------------
* Rules: general
* ------------------------------------------------------------------------- */
/* General node */
.node circle,
.node ellipse,
.node path,
.node polygon,
.node rect {
fill: var(--md-mermaid-node-bg-color);
stroke: var(--md-mermaid-node-fg-color);
}
/* General marker */
marker {
fill: var(--md-mermaid-edge-color) !important;
}
/* General edge label */
.edgeLabel .label rect {
fill: transparent;
}
/* ----------------------------------------------------------------------------
* Rules: flowcharts
* ------------------------------------------------------------------------- */
/* Flowchart title */
.flowchartTitleText {
fill: var(--md-mermaid-label-fg-color);
}
/* Flowchart node label */
.label {
color: var(--md-mermaid-label-fg-color);
font-family: var(--md-mermaid-font-family);
}
/* Flowchart node label container */
.label foreignObject {
overflow: visible;
line-height: initial;
}
/* Flowchart edge label in node label */
.label div .edgeLabel {
color: var(--md-mermaid-label-fg-color);
background-color: var(--md-mermaid-label-bg-color);
}
/* Flowchart edge label */
.edgeLabel,
.edgeLabel p {
color: var(--md-mermaid-edge-color);
background-color: var(--md-mermaid-label-bg-color);
fill: var(--md-mermaid-label-bg-color);
}
/* Flowchart edge path */
.edgePath .path,
.flowchart-link {
stroke: var(--md-mermaid-edge-color);
}
/* Flowchart arrow head */
.edgePath .arrowheadPath {
fill: var(--md-mermaid-edge-color);
stroke: none;
}
/* Flowchart subgraph */
.cluster rect {
fill: var(--md-default-fg-color--lightest);
stroke: var(--md-default-fg-color--lighter);
}
/* Flowchart subgraph labels */
.cluster span {
color: var(--md-mermaid-label-fg-color);
font-family: var(--md-mermaid-font-family);
}
/* Flowchart markers */
g #flowchart-circleStart,
g #flowchart-circleEnd,
g #flowchart-crossStart,
g #flowchart-crossEnd,
g #flowchart-pointStart,
g #flowchart-pointEnd {
stroke: none;
}
/* ----------------------------------------------------------------------------
* Rules: class diagrams
* ------------------------------------------------------------------------- */
/* Class diagram title */
.classDiagramTitleText {
fill: var(--md-mermaid-label-fg-color);
}
/* Class group node */
g.classGroup line,
g.classGroup rect {
fill: var(--md-mermaid-node-bg-color);
stroke: var(--md-mermaid-node-fg-color);
}
/* Class group node text */
g.classGroup text {
font-family: var(--md-mermaid-font-family);
fill: var(--md-mermaid-label-fg-color);
}
/* Class label box */
.classLabel .box {
background-color: var(--md-mermaid-label-bg-color);
opacity: 1;
fill: var(--md-mermaid-label-bg-color);
}
/* Class label text */
.classLabel .label {
font-family: var(--md-mermaid-font-family);
fill: var(--md-mermaid-label-fg-color);
}
/* Class group divider */
.node .divider {
stroke: var(--md-mermaid-node-fg-color);
}
/* Class relation */
.relation {
stroke: var(--md-mermaid-edge-color);
}
/* Class relation cardinality */
.cardinality {
font-family: var(--md-mermaid-font-family);
fill: var(--md-mermaid-label-fg-color);
}
/* Class relation cardinality text */
.cardinality text {
fill: inherit !important;
}
/* Class extension, composition and dependency marker */
defs marker.marker.extension.class path,
defs marker.marker.composition.class path ,
defs marker.marker.dependency.class path {
fill: var(--md-mermaid-edge-color) !important;
stroke: var(--md-mermaid-edge-color) !important;
}
/* Class aggregation marker */
defs marker.marker.aggregation.class path {
fill: var(--md-mermaid-label-bg-color) !important;
stroke: var(--md-mermaid-edge-color) !important;
}
/* ----------------------------------------------------------------------------
* Rules: state diagrams
* ------------------------------------------------------------------------- */
/* State diagram title */
.statediagramTitleText {
fill: var(--md-mermaid-label-fg-color);
}
/* State group node */
g.stateGroup rect {
fill: var(--md-mermaid-node-bg-color);
stroke: var(--md-mermaid-node-fg-color);
}
/* State group title */
g.stateGroup .state-title {
font-family: var(--md-mermaid-font-family);
fill: var(--md-mermaid-label-fg-color) !important;
}
/* State group background */
g.stateGroup .composit {
fill: var(--md-mermaid-label-bg-color);
}
/* State node label */
.nodeLabel,
.nodeLabel p {
color: var(--md-mermaid-label-fg-color);
font-family: var(--md-mermaid-font-family);
}
/* State node label link */
a .nodeLabel {
text-decoration: underline;
}
/* State start and end marker */
.start-state,
.node circle.state-start,
.node circle.state-end {
fill: var(--md-mermaid-edge-color);
stroke: none;
}
/* State end marker */
.end-state-outer,
.end-state-inner {
fill: var(--md-mermaid-edge-color);
}
/* State end marker */
.end-state-inner,
.node circle.state-end {
stroke: var(--md-mermaid-label-bg-color);
}
/* State transition */
.transition {
stroke: var(--md-mermaid-edge-color);
}
/* State fork and join */
[id^=state-fork] rect,
[id^=state-join] rect {
fill: var(--md-mermaid-edge-color) !important;
stroke: none !important;
}
/* State cluster (yes, 2x... Mermaid WTF) */
.statediagram-cluster.statediagram-cluster .inner {
fill: var(--md-default-bg-color);
}
/* State cluster node */
.statediagram-cluster rect {
fill: var(--md-mermaid-node-bg-color);
stroke: var(--md-mermaid-node-fg-color);
}
/* State cluster divider */
.statediagram-state rect.divider {
fill: var(--md-default-fg-color--lightest);
stroke: var(--md-default-fg-color--lighter);
}
/* State diagram markers */
defs #statediagram-barbEnd {
stroke: var(--md-mermaid-edge-color);
}
/* ----------------------------------------------------------------------------
* Rules: entity-relationship diagrams
* ------------------------------------------------------------------------- */
/* Entity node and path - override color or markers will shine through */
[id^=entity] rect,
[id^=entity] path {
fill: var(--md-default-bg-color);
}
/* Entity relationship line */
.relationshipLine {
stroke: var(--md-mermaid-edge-color);
}
/* Entity relationship line markers */
defs .marker.onlyOne.er *,
defs .marker.zeroOrOne.er *,
defs .marker.oneOrMore.er *,
defs .marker.zeroOrMore.er * {
stroke: var(--md-mermaid-edge-color) !important;
}
/* ----------------------------------------------------------------------------
* Rules: sequence diagrams
* ------------------------------------------------------------------------- */
/* Sequence diagram title */
text:not([class]):last-child {
fill: var(--md-mermaid-label-fg-color);
}
/* Sequence actor */
.actor {
fill: var(--md-mermaid-sequence-actor-bg-color);
stroke: var(--md-mermaid-sequence-actor-border-color);
}
/* Sequence actor text */
text.actor > tspan {
font-family: var(--md-mermaid-font-family);
fill: var(--md-mermaid-sequence-actor-fg-color);
}
/* Sequence actor line */
line {
stroke: var(--md-mermaid-sequence-actor-line-color);
}
/* Sequence actor */
.actor-man circle,
.actor-man line {
fill: var(--md-mermaid-sequence-actorman-bg-color);
stroke: var(--md-mermaid-sequence-actorman-line-color);
}
/* Sequence message line */
.messageLine0,
.messageLine1 {
stroke: var(--md-mermaid-sequence-message-line-color);
}
/* Sequence note */
.note {
fill: var(--md-mermaid-sequence-note-bg-color);
stroke: var(--md-mermaid-sequence-note-border-color);
}
/* Sequence message, loop and note text */
.messageText,
.loopText,
.loopText > tspan,
.noteText > tspan {
font-family: var(--md-mermaid-font-family) !important;
stroke: none;
}
/* Sequence message text */
.messageText {
fill: var(--md-mermaid-sequence-message-fg-color);
}
/* Sequence loop text */
.loopText,
.loopText > tspan {
fill: var(--md-mermaid-sequence-loop-fg-color);
}
/* Sequence note text */
.noteText > tspan {
fill: var(--md-mermaid-sequence-note-fg-color);
}
/* Sequence arrow head */
#arrowhead path {
fill: var(--md-mermaid-sequence-message-line-color);
stroke: none;
}
/* Sequence loop line */
.loopLine {
fill: var(--md-mermaid-sequence-loop-bg-color);
stroke: var(--md-mermaid-sequence-loop-border-color);
}
/* Sequence label box */
.labelBox {
fill: var(--md-mermaid-sequence-label-bg-color);
stroke: none;
}
/* Sequence label text */
.labelText,
.labelText > span {
font-family: var(--md-mermaid-font-family);
fill: var(--md-mermaid-sequence-label-fg-color);
}
/* Sequence number */
.sequenceNumber {
fill: var(--md-mermaid-sequence-number-fg-color);
}
/* Sequence rectangle */
rect.rect {
fill: var(--md-mermaid-sequence-box-bg-color);
stroke: none;
}
/* Sequence rectangle text */
rect.rect + text.text {
fill: var(--md-mermaid-sequence-box-fg-color);
}
/* Sequence diagram markers */
defs #sequencenumber {
fill: var(--md-mermaid-sequence-number-bg-color) !important;
}

View File

@@ -0,0 +1,132 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
map,
of,
shareReplay,
tap
} from "rxjs"
import { watchScript } from "~/browser"
import { h } from "~/utilities"
import { Component } from "../../_"
import themeCSS from "./index.css"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Mermaid diagram
*/
export interface Mermaid {}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Mermaid instance observable
*/
let mermaid$: Observable<void>
/**
* Global sequence number for diagrams
*/
let sequence = 0
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Fetch Mermaid script
*
* @returns Mermaid scripts observable
*/
function fetchScripts(): Observable<void> {
return typeof mermaid === "undefined" || mermaid instanceof Element
? watchScript("https://unpkg.com/mermaid@11/dist/mermaid.min.js")
: of(undefined)
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount Mermaid diagram
*
* @param el - Code block element
*
* @returns Mermaid diagram component observable
*/
export function mountMermaid(
el: HTMLElement
): Observable<Component<Mermaid>> {
el.classList.remove("mermaid") // Hack: mitigate https://bit.ly/3CiN6Du
mermaid$ ||= fetchScripts()
.pipe(
tap(() => mermaid.initialize({
startOnLoad: false,
themeCSS,
sequence: {
actorFontSize: "16px", // Hack: mitigate https://bit.ly/3y0NEi3
messageFontSize: "16px",
noteFontSize: "16px"
}
})),
map(() => undefined),
shareReplay(1)
)
/* Render diagram */
mermaid$.subscribe(async () => {
el.classList.add("mermaid") // Hack: mitigate https://bit.ly/3CiN6Du
const id = `__mermaid_${sequence++}`
/* Create host element to replace code block */
const host = h("div", { class: "mermaid" })
const text = el.textContent
/* Render and inject diagram */
const { svg, fn } = await mermaid.render(id, text)
/* Create a shadow root and inject diagram */
const shadow = host.attachShadow({ mode: "closed" })
shadow.innerHTML = svg
/* Replace code block with diagram and bind functions */
el.replaceWith(host)
fn?.(shadow)
})
/* Create and return component */
return mermaid$
.pipe(
map(() => ({ ref: el }))
)
}

View File

@@ -20,76 +20,51 @@
* IN THE SOFTWARE.
*/
import { wrap } from "fuzzaldrin-plus"
import { Observable, of } from "rxjs"
import { translation } from "~/_"
import { renderTable } from "~/templates"
import { h } from "~/utilities"
import { Component } from "../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Icon
* Data table
*/
export interface Icon {
shortcode: string /* Icon shortcode */
url: string /* Icon URL */
}
export interface DataTable {}
/* ----------------------------------------------------------------------------
* Helper functions
* Data
* ------------------------------------------------------------------------- */
/**
* Highlight an icon search result
*
* @param icon - Icon
* @param query - Search query
*
* @returns Highlighted result
* Sentinel for replacement
*/
function highlight(icon: Icon, query: string): string {
return wrap(icon.shortcode, query, {
wrap: {
tagOpen: "<b>",
tagClose: "</b>"
}
})
}
const sentinel = h("table")
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Render an icon search result
* Mount data table
*
* @param icon - Icon
* @param query - Search query
* @param file - Render as file
* This function wraps a data table in another scrollable container, so it can
* be smoothly scrolled on smaller screen sizes and won't break the layout.
*
* @returns Element
* @param el - Data table element
*
* @returns Data table component observable
*/
export function renderIconSearchResult(
icon: Icon, query: string, file?: boolean
): HTMLElement {
return (
<li class="mdx-iconsearch-result__item">
<span class="twemoji">
<img src={icon.url} />
</span>
<button
class="md-clipboard--inline"
title={translation("clipboard.copy")}
data-clipboard-text={file ? icon.shortcode : `:${icon.shortcode}:`}
>
<code>{
file
? highlight(icon, query)
: `:${highlight(icon, query)}:`
}</code>
</button>
</li>
)
export function mountDataTable(
el: HTMLElement
): Observable<Component<DataTable>> {
el.replaceWith(sentinel)
sentinel.replaceWith(renderTable(el))
/* Create and return component */
return of({ ref: el })
}

View File

@@ -0,0 +1,305 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
Subject,
animationFrameScheduler,
asyncScheduler,
auditTime,
combineLatest,
defer,
endWith,
filter,
finalize,
fromEvent,
ignoreElements,
map,
merge,
skip,
startWith,
subscribeOn,
takeUntil,
tap,
withLatestFrom
} from "rxjs"
import { feature } from "~/_"
import {
Viewport,
getElement,
getElementContentOffset,
getElementContentSize,
getElementOffset,
getElementSize,
getElements,
watchElementContentOffset,
watchElementSize,
watchElementVisibility
} from "~/browser"
import { renderTabbedControl } from "~/templates"
import { h } from "~/utilities"
import { Component } from "../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Content tabs
*/
export interface ContentTabs {
active: HTMLLabelElement /* Active tab label */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
viewport$: Observable<Viewport> /* Viewport observable */
target$: Observable<HTMLElement> /* Location target observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch content tabs
*
* @param inputs - Content tabs input elements
*
* @returns Content tabs observable
*/
export function watchContentTabs(
inputs: HTMLInputElement[]
): Observable<ContentTabs> {
const initial = inputs.find(input => input.checked) || inputs[0]
return merge(...inputs.map(input => fromEvent(input, "change")
.pipe(
map(() => getElement<HTMLLabelElement>(`label[for="${input.id}"]`))
)
))
.pipe(
startWith(getElement<HTMLLabelElement>(`label[for="${initial.id}"]`)),
map(active => ({ active }))
)
}
/**
* Mount content tabs
*
* @param el - Content tabs element
* @param options - Options
*
* @returns Content tabs component observable
*/
export function mountContentTabs(
el: HTMLElement, { viewport$, target$ }: MountOptions
): Observable<Component<ContentTabs>> {
const container = getElement(".tabbed-labels", el)
const inputs = getElements<HTMLInputElement>(":scope > input", el)
/* Render content tab previous button for pagination */
const prev = renderTabbedControl("prev")
el.append(prev)
/* Render content tab next button for pagination */
const next = renderTabbedControl("next")
el.append(next)
/* Mount component on subscription */
return defer(() => {
const push$ = new Subject<ContentTabs>()
const done$ = push$.pipe(ignoreElements(), endWith(true))
combineLatest([push$, watchElementSize(el), watchElementVisibility(el)])
.pipe(
takeUntil(done$),
auditTime(1, animationFrameScheduler)
)
.subscribe({
/* Handle emission */
next([{ active }, size]) {
const offset = getElementOffset(active)
const { width } = getElementSize(active)
/* Set tab indicator offset and width */
el.style.setProperty("--md-indicator-x", `${offset.x}px`)
el.style.setProperty("--md-indicator-width", `${width}px`)
/* Scroll container to active content tab */
const content = getElementContentOffset(container)
if (
offset.x < content.x ||
offset.x + width > content.x + size.width
)
container.scrollTo({
left: Math.max(0, offset.x - 16),
behavior: "smooth"
})
},
/* Handle complete */
complete() {
el.style.removeProperty("--md-indicator-x")
el.style.removeProperty("--md-indicator-width")
}
})
/* Hide content tab buttons on borders */
combineLatest([
watchElementContentOffset(container),
watchElementSize(container)
])
.pipe(
takeUntil(done$)
)
.subscribe(([offset, size]) => {
const content = getElementContentSize(container)
prev.hidden = offset.x < 16
next.hidden = offset.x > content.width - size.width - 16
})
/* Paginate content tab container on click */
merge(
fromEvent(prev, "click").pipe(map(() => -1)),
fromEvent(next, "click").pipe(map(() => +1))
)
.pipe(
takeUntil(done$)
)
.subscribe(direction => {
const { width } = getElementSize(container)
container.scrollBy({
left: width * direction,
behavior: "smooth"
})
})
/* Switch to content tab target */
target$
.pipe(
takeUntil(done$),
filter(input => inputs.includes(input as HTMLInputElement))
)
.subscribe(input => input.click())
/* Add link to each content tab label */
container.classList.add("tabbed-labels--linked")
for (const input of inputs) {
const label = getElement<HTMLLabelElement>(`label[for="${input.id}"]`)
label.replaceChildren(h("a", {
href: `#${label.htmlFor}`,
tabIndex: -1
}, ...Array.from(label.childNodes)))
/* Allow to copy link without scrolling to anchor */
fromEvent<MouseEvent>(label.firstElementChild!, "click")
.pipe(
takeUntil(done$),
filter(ev => !(ev.metaKey || ev.ctrlKey)),
tap(ev => {
ev.preventDefault()
ev.stopPropagation()
})
)
// @todo we might need to remove the anchor link on complete
.subscribe(() => {
history.replaceState({}, "", `#${label.htmlFor}`)
label.click()
})
}
/* Set up linking of content tabs, if enabled */
if (feature("content.tabs.link"))
push$.pipe(
skip(1),
withLatestFrom(viewport$)
)
.subscribe(([{ active }, { offset }]) => {
const tab = active.innerText.trim()
if (active.hasAttribute("data-md-switching")) {
active.removeAttribute("data-md-switching")
/* Determine viewport offset of active tab */
} else {
const y = el.offsetTop - offset.y
/* Passively activate other tabs */
for (const set of getElements("[data-tabs]"))
for (const input of getElements<HTMLInputElement>(
":scope > input", set
)) {
const label = getElement(`label[for="${input.id}"]`)
if (
label !== active &&
label.innerText.trim() === tab
) {
label.setAttribute("data-md-switching", "")
input.click()
break
}
}
/* Bring active tab into view */
window.scrollTo({
top: el.offsetTop - y
})
/* Persist active tabs in local storage */
const tabs = __md_get<string[]>("__tabs") || []
__md_set("__tabs", [...new Set([tab, ...tabs])])
}
})
/* Pause media (audio, video) on switch - see https://bit.ly/3Bk6cel */
push$.pipe(takeUntil(done$))
.subscribe(() => {
// If the video or audio is visible, and has autoplay enabled, it will
// continue playing. If it's not visible, it is paused in any case
for (const media of getElements<HTMLAudioElement>("audio, video", el)) {
if (media.offsetWidth && media.autoplay) {
media.play().catch(() => {}) // Just ignore errors
} else {
media.pause()
}
}
})
/* Create and return component */
return watchContentTabs(inputs)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
.pipe(
subscribeOn(asyncScheduler)
)
}

View File

@@ -0,0 +1,128 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
Subject,
defer,
delay,
finalize,
map,
merge,
of,
switchMap,
tap
} from "rxjs"
import { getElement } from "~/browser"
import { Component } from "../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Dialog
*/
export interface Dialog {
message: string /* Dialog message */
active: boolean /* Dialog is active */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
alert$: Subject<string> /* Alert subject */
}
/**
* Mount options
*/
interface MountOptions {
alert$: Subject<string> /* Alert subject */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch dialog
*
* @param _el - Dialog element
* @param options - Options
*
* @returns Dialog observable
*/
export function watchDialog(
_el: HTMLElement, { alert$ }: WatchOptions
): Observable<Dialog> {
return alert$
.pipe(
switchMap(message => merge(
of(true),
of(false).pipe(delay(2000))
)
.pipe(
map(active => ({ message, active }))
)
)
)
}
/**
* Mount dialog
*
* This function reveals the dialog in the right corner when a new alert is
* emitted through the subject that is passed as part of the options.
*
* @param el - Dialog element
* @param options - Options
*
* @returns Dialog component observable
*/
export function mountDialog(
el: HTMLElement, options: MountOptions
): Observable<Component<Dialog>> {
const inner = getElement(".md-typeset", el)
return defer(() => {
const push$ = new Subject<Dialog>()
push$.subscribe(({ message, active }) => {
el.classList.toggle("md-dialog--active", active)
inner.textContent = message
})
/* Create and return component */
return watchDialog(el, options)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -0,0 +1,208 @@
/*
* Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
Subject,
bufferCount,
combineLatest,
combineLatestWith,
defer,
distinctUntilChanged,
distinctUntilKeyChanged,
endWith,
filter,
from,
ignoreElements,
map,
mergeMap,
mergeWith,
of,
shareReplay,
startWith,
switchMap,
takeUntil
} from "rxjs"
import { feature } from "~/_"
import {
Viewport,
getElements,
watchElementSize,
watchToggle
} from "~/browser"
import { Component } from "../../_"
import { Main } from "../../main"
import { Tooltip, mountTooltip } from "../../tooltip"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Header
*/
export interface Header {
height: number /* Header visible height */
hidden: boolean /* Header is hidden */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Dependencies
*/
interface Dependencies {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
main$: Observable<Main> /* Main area observable */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Compute whether the header is hidden
*
* If the user scrolls past a certain threshold, the header can be hidden when
* scrolling down, and shown when scrolling up.
*
* @param dependencies - Dependencies
*
* @returns Toggle observable
*/
function isHidden(
{ viewport$ }: Pick<Dependencies, "viewport$">
): Observable<boolean> {
if (!feature("header.autohide"))
return of(false)
/* Compute direction and turning point */
const direction$ = viewport$
.pipe(
map(({ offset: { y } }) => y),
bufferCount(2, 1),
map(([a, b]) => [a < b, b] as const),
distinctUntilKeyChanged(0)
)
/* Compute whether header should be hidden */
const hidden$ = combineLatest([viewport$, direction$])
.pipe(
filter(([{ offset }, [, y]]) => Math.abs(y - offset.y) > 100),
map(([, [direction]]) => direction),
distinctUntilChanged()
)
/* Compute threshold for hiding */
const search$ = watchToggle("search")
return combineLatest([viewport$, search$])
.pipe(
map(([{ offset }, search]) => offset.y > 400 && !search),
distinctUntilChanged(),
switchMap(active => active ? hidden$ : of(false)),
startWith(false)
)
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch header
*
* @param el - Header element
* @param dependencies - Dependencies
*
* @returns Header observable
*/
export function watchHeader(
el: HTMLElement, dependencies: Pick<Dependencies, "viewport$">
): Observable<Header> {
return defer(() => combineLatest([
watchElementSize(el),
isHidden(dependencies)
]))
.pipe(
map(([{ height }, hidden]) => ({
height,
hidden
})),
distinctUntilChanged((a, b) => (
a.height === b.height &&
a.hidden === b.hidden
)),
shareReplay(1)
)
}
/**
* Mount header
*
* This function manages the different states of the header, i.e. whether it's
* hidden or rendered with a shadow. This depends heavily on the main area.
*
* @param el - Header element
* @param dependencies - Dependencies
*
* @returns Header component observable
*/
export function mountHeader(
el: HTMLElement, { header$, main$ }: Dependencies
): Observable<Component<Header | Tooltip>> {
return defer(() => {
const push$ = new Subject<Main>()
const done$ = push$.pipe(ignoreElements(), endWith(true))
push$
.pipe(
distinctUntilKeyChanged("active"),
combineLatestWith(header$)
)
.subscribe(([{ active }, { hidden }]) => {
el.classList.toggle("md-header--shadow", active && !hidden)
el.hidden = hidden
})
/* Mount tooltips, if enabled */
const tooltips = from(getElements("[title]", el))
.pipe(
filter(() => feature("content.tooltips")),
mergeMap(child => mountTooltip(child))
)
/* Link to main area */
main$.subscribe(push$)
/* Create and return component */
return header$
.pipe(
takeUntil(done$),
map(state => ({ ref: el, ...state })),
mergeWith(tooltips.pipe(takeUntil(done$)))
)
})
}

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