Merge Insiders features

This commit is contained in:
squidfunk
2025-11-06 19:02:35 +01:00
committed by Martin Donath
parent 9853cc3a10
commit 764178b012
240 changed files with 6113 additions and 22171 deletions

View File

@@ -33,9 +33,9 @@ WORKDIR /tmp
# Copy files necessary for build
COPY material material
COPY package.json package.json
COPY pyproject.toml pyproject.toml
COPY README.md README.md
COPY *requirements.txt ./
COPY pyproject.toml pyproject.toml
# Perform build and cleanup artifacts and caches
RUN \
@@ -48,6 +48,7 @@ RUN \
git-fast-import \
jpeg-dev \
openssh \
pngquant \
tini \
zlib-dev \
&& \
@@ -64,6 +65,7 @@ RUN \
if [ "${WITH_PLUGINS}" = "true" ]; then \
pip install --no-cache-dir \
mkdocs-material[recommended] \
mkdocs-material[git] \
mkdocs-material[imaging]; \
fi \
&& \

View File

@@ -1,3 +0,0 @@
{
"origins": ["https://squidfunk.github.io"]
}

View File

@@ -79,6 +79,7 @@ Issues = "https://github.com/squidfunk/mkdocs-material/issues"
"material/info" = "material.plugins.info.plugin:InfoPlugin"
"material/meta" = "material.plugins.meta.plugin:MetaPlugin"
"material/offline" = "material.plugins.offline.plugin:OfflinePlugin"
"material/optimize" = "material.plugins.optimize.plugin:OptimizePlugin"
"material/privacy" = "material.plugins.privacy.plugin:PrivacyPlugin"
"material/search" = "material.plugins.search.plugin:SearchPlugin"
"material/social" = "material.plugins.social.plugin:SocialPlugin"

223
src/extensions/preview.py Normal file
View File

@@ -0,0 +1,223 @@
# 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.
from __future__ import annotations
import logging
from material.utilities.filter import FileFilter, FilterConfig
from mkdocs.structure.pages import _RelativePathTreeprocessor
from markdown import Extension, Markdown
from markdown.treeprocessors import Treeprocessor
from mkdocs.exceptions import ConfigurationError
from urllib.parse import urlparse
from xml.etree.ElementTree import Element
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class PreviewProcessor(Treeprocessor):
"""
A Markdown treeprocessor to enable instant previews on links.
Note that this treeprocessor is dependent on the `relpath` treeprocessor
registered programmatically by MkDocs before rendering a page.
"""
def __init__(self, md: Markdown, config: dict):
"""
Initialize the treeprocessor.
Arguments:
md: The Markdown instance.
config: The configuration.
"""
super().__init__(md)
self.config = config
def run(self, root: Element):
"""
Run the treeprocessor.
Arguments:
root: The root element of the parsed Markdown document.
"""
at = self.md.treeprocessors.get_index_for_name("relpath")
# Hack: Python Markdown has no notion of where it is, i.e., which file
# is being processed. This seems to be a deliberate design decision, as
# it is not possible to access the file path of the current page, but
# it might also be an oversight that is now impossible to fix. However,
# since this extension is only useful in the context of Material for
# MkDocs, we can assume that the _RelativePathTreeprocessor is always
# present, telling us the file path of the current page. If that ever
# changes, we would need to wrap this extension in a plugin, but for
# the time being we are sneaky and will probably get away with it.
processor = self.md.treeprocessors[at]
if not isinstance(processor, _RelativePathTreeprocessor):
raise TypeError("Relative path processor not registered")
# Normalize configurations
configurations = self.config["configurations"]
configurations.append({
"sources": self.config.get("sources"),
"targets": self.config.get("targets")
})
# Walk through all configurations - @todo refactor so that we don't
# iterate multiple times over the same elements
for configuration in configurations:
# Skip, if the configuration defines nothing we could also fix
# this in the file filter, but we first fix it here and check if
# it generalizes well enough to other inclusion/exclusion sites,
# because here, it would hinder the ability to automaticaly
# include all sources, while excluding specific targets.
if (
not configuration.get("sources") and
not configuration.get("targets")
):
continue
# Skip if page should not be considered
filter = get_filter(configuration, "sources")
if not filter(processor.file):
continue
# Walk through all links and add preview attributes
filter = get_filter(configuration, "targets")
for el in root.iter("a"):
href = el.get("href")
if not href:
continue
# Skip footnotes
if "footnote-ref" in el.get("class", ""):
continue
# Skip external links
url = urlparse(href)
if url.scheme or url.netloc:
continue
# Add preview attribute to internal links
for path in processor._possible_target_uris(
processor.file, url.path,
processor.config.use_directory_urls
):
target = processor.files.get_file_from_path(path)
if not target:
continue
# Include, if filter matches
if filter(target):
el.set("data-preview", "")
# -----------------------------------------------------------------------------
class PreviewExtension(Extension):
"""
A Markdown extension to enable instant previews on links.
This extensions allows to automatically add the `data-preview` attribute to
internal links matching specific criteria, so Material for MkDocs renders a
nice preview on hover as part of a tooltip. It is the recommended way to
add previews to links in a programmatic way.
"""
def __init__(self, *args, **kwargs):
"""
"""
self.config = {
"configurations": [[], "Filter configurations"],
"sources": [{}, "Link sources"],
"targets": [{}, "Link targets"]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md: Markdown):
"""
Register Markdown extension.
Arguments:
md: The Markdown instance.
"""
md.registerExtension(self)
# Create and register treeprocessor - we use the same priority as the
# `relpath` treeprocessor, the latter of which is guaranteed to run
# after our treeprocessor, so we can check the original Markdown URIs
# before they are resolved to URLs.
processor = PreviewProcessor(md, self.getConfigs())
md.treeprocessors.register(processor, "preview", 0)
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def get_filter(settings: dict, key: str):
"""
Get file filter from settings.
Arguments:
settings: The settings.
key: The key in the settings.
Returns:
The file filter.
"""
config = FilterConfig()
config.load_dict(settings.get(key) or {})
# Validate filter configuration
errors, warnings = config.validate()
for _, w in warnings:
log.warning(
f"Error reading filter configuration in '{key}':\n"
f"{w}"
)
for _, e in errors:
raise ConfigurationError(
f"Error reading filter configuration in '{key}':\n"
f"{e}"
)
# Return file filter
return FileFilter(config = config) # type: ignore
def makeExtension(**kwargs):
"""
Register Markdown extension.
Arguments:
**kwargs: Configuration options.
Returns:
The Markdown extension.
"""
return PreviewExtension(**kwargs)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.extensions.preview")

View File

@@ -17,3 +17,17 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from .structure import View
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Sort views by name
def view_name(view: View):
return view.name
# Sort views by post count
def view_post_count(view: View):
return len(view.posts)

View File

@@ -23,6 +23,8 @@ from mkdocs.config.config_options import Choice, Deprecated, Optional, Type
from mkdocs.config.base import Config
from pymdownx.slugs import slugify
from . import view_name
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
@@ -56,6 +58,8 @@ class BlogConfig(Config):
archive_date_format = Type(str, default = "yyyy")
archive_url_date_format = Type(str, default = "yyyy")
archive_url_format = Type(str, default = "archive/{date}")
archive_pagination = Optional(Type(bool))
archive_pagination_per_page = Optional(Type(int))
archive_toc = Optional(Type(bool))
# Settings for categories
@@ -64,12 +68,22 @@ class BlogConfig(Config):
categories_url_format = Type(str, default = "category/{slug}")
categories_slugify = Type(Callable, default = slugify(case = "lower"))
categories_slugify_separator = Type(str, default = "-")
categories_sort_by = Type(Callable, default = view_name)
categories_sort_reverse = Type(bool, default = False)
categories_allowed = Type(list, default = [])
categories_pagination = Optional(Type(bool))
categories_pagination_per_page = Optional(Type(int))
categories_toc = Optional(Type(bool))
# Settings for authors
authors = Type(bool, default = True)
authors_file = Type(str, default = "{blog}/.authors.yml")
authors_profiles = Type(bool, default = False)
authors_profiles_name = Type(str, default = "blog.authors")
authors_profiles_url_format = Type(str, default = "author/{slug}")
authors_profiles_pagination = Optional(Type(bool))
authors_profiles_pagination_per_page = Optional(Type(int))
authors_profiles_toc = Optional(Type(bool))
# Settings for pagination
pagination = Type(bool, default = True)

View File

@@ -45,10 +45,15 @@ from tempfile import mkdtemp
from urllib.parse import urlparse
from yaml import SafeLoader
from .author import Authors
from . import view_name
from .author import Author, Authors
from .config import BlogConfig
from .readtime import readtime
from .structure import Archive, Category, Excerpt, Post, Reference, View
from .structure import (
Archive, Category, Profile,
Excerpt, Post, View,
Reference
)
# -----------------------------------------------------------------------------
# Classes
@@ -86,12 +91,6 @@ class BlogPlugin(BasePlugin[BlogConfig]):
if self.config.authors:
self.authors = self._resolve_authors(config)
# Initialize table of contents settings
if not isinstance(self.config.archive_toc, bool):
self.config.archive_toc = self.config.blog_toc
if not isinstance(self.config.categories_toc, bool):
self.config.categories_toc = self.config.blog_toc
# By default, drafts are rendered when the documentation is served,
# but not when it is built, for a better user experience
if self.is_serve and self.config.draft_on_serve:
@@ -134,27 +133,40 @@ class BlogPlugin(BasePlugin[BlogConfig]):
self.blog = self._resolve(files, config)
self.blog.posts = sorted(
self._resolve_posts(files, config),
key = lambda post: post.config.date.created,
key = lambda post: (
post.config.pin,
post.config.date.created
),
reverse = True
)
# Generate views for archive
if self.config.archive:
self.blog.views.extend(
self._generate_archive(config, files)
)
views = self._generate_archive(config, files)
self.blog.views.extend(views)
# Generate views for categories
if self.config.categories:
views = self._generate_categories(config, files)
# We always sort the list of categories by name first, so that any
# custom sorting function that returns the same value for two items
# returns them in a predictable and logical order, because sorting
# in Python is stable, i.e., order of equal items is preserved
self.blog.views.extend(sorted(
self._generate_categories(config, files),
key = lambda view: view.name,
reverse = False
sorted(views, key = view_name),
key = self.config.categories_sort_by,
reverse = self.config.categories_sort_reverse
))
# Generate views for profiles
if self.config.authors_profiles:
views = self._generate_profiles(config, files)
self.blog.views.extend(views)
# Generate pages for views
if self.config.pagination:
for view in self._resolve_views(self.blog):
for view in self._resolve_views(self.blog):
if self._config_pagination(view):
for page in self._generate_pages(view, config, files):
view.pages.append(page)
@@ -209,9 +221,18 @@ class BlogPlugin(BasePlugin[BlogConfig]):
if self.blog.file.inclusion.is_in_nav() and views:
self._attach_to(self.blog, Section(title, views), nav)
# Attach views for profiles
if self.config.authors_profiles:
title = self._translate(self.config.authors_profiles_name, config)
views = [_ for _ in self.blog.views if isinstance(_, Profile)]
# Attach and link views for categories, if any
if self.blog.file.inclusion.is_in_nav() and views:
self._attach_to(self.blog, Section(title, views), nav)
# Attach pages for views
if self.config.pagination:
for view in self._resolve_views(self.blog):
for view in self._resolve_views(self.blog):
if self._config_pagination(view):
for at in range(1, len(view.pages)):
self._attach_at(view.parent, view, view.pages[at])
@@ -227,7 +248,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# Skip if page is not a post managed by this instance - this plugin has
# support for multiple instances, which is why this check is necessary
if page not in self.blog.posts:
if not self.config.pagination:
if not self._config_pagination(page):
return
# We set the contents of the view to its title if pagination should
@@ -250,12 +271,12 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# Extract and assign authors to post, if enabled
if self.config.authors:
for name in page.config.authors:
if name not in self.authors:
raise PluginError(f"Couldn't find author '{name}'")
for id in page.config.authors:
if id not in self.authors:
raise PluginError(f"Couldn't find author '{id}'")
# Append to list of authors
page.authors.append(self.authors[name])
page.authors.append(self.authors[id])
# Extract settings for excerpts
separator = self.config.post_excerpt_separator
@@ -314,7 +335,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
url_filter = env.filters["url"]
# Patch URL template filter to add support for paginated views, i.e.,
# that paginated views never link to themselves but to the main view
# that paginated views never link to themselves but to the main vie
@pass_context
def url_filter_with_pagination(context: Context, url: str | None):
page = context["page"]
@@ -590,6 +611,37 @@ class BlogPlugin(BasePlugin[BlogConfig]):
file.page.posts.append(post)
post.categories.append(file.page)
# Generate views for profiles - analyze posts and generate the necessary
# views to provide a profile page for each author listing all posts
def _generate_profiles(self, config: MkDocsConfig, files: Files):
for post in self.blog.posts:
for id in post.config.authors:
author = self.authors[id]
path = self._format_path_for_profile(id, author)
# Create file for view, if it does not exist
file = files.get_file_from_path(path)
if not file:
file = self._path_to_file(path, config)
files.append(file)
# Create file in temporary directory
self._save_to_file(file.abs_src_path, f"# {author.name}")
# Temporarily remove view from navigation and assign profile
# URL to author, if not explicitly set
file.inclusion = InclusionLevel.EXCLUDED
if not author.url:
author.url = file.url
# Create and yield view
if not isinstance(file.page, Profile):
yield Profile(author.name, file, config)
# Assign post to profile
assert isinstance(file.page, Profile)
file.page.posts.append(post)
# Generate pages for pagination - analyze view and generate the necessary
# pages, creating a chain of views for simple rendering and replacement
def _generate_pages(self, view: View, config: MkDocsConfig, files: Files):
@@ -597,7 +649,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# Compute pagination boundaries and create pages - pages are internally
# handled as copies of a view, as they map to the same source location
step = self.config.pagination_per_page
step = self._config_pagination_per_page(view)
for at in range(step, len(view.posts), step):
path = self._format_path_for_pagination(view, 1 + at // step)
@@ -747,11 +799,11 @@ class BlogPlugin(BasePlugin[BlogConfig]):
posts, pagination = view.posts, None
# Create pagination, if enabled
if self.config.pagination:
if self._config_pagination(view):
at = view.pages.index(view)
# Compute pagination boundaries
step = self.config.pagination_per_page
step = self._config_pagination_per_page(view)
p, q = at * step, at * step + step
# Extract posts in pagination boundaries
@@ -771,18 +823,9 @@ class BlogPlugin(BasePlugin[BlogConfig]):
def _render_post(self, excerpt: Excerpt, view: View):
excerpt.render(view, self.config.post_excerpt_separator)
# Determine whether to add posts to the table of contents of the view -
# note that those settings can be changed individually for each type of
# view, which is why we need to check the type of view and the table of
# contents setting for that type of view
toc = self.config.blog_toc
if isinstance(view, Archive):
toc = self.config.archive_toc
if isinstance(view, Category):
toc = self.config.categories_toc
# Attach top-level table of contents item to view if it should be added
# and both, the view and excerpt contain table of contents items
toc = self._config_toc(view)
if toc and excerpt.toc.items and view.toc.items:
view.toc.items[0].children.append(excerpt.toc.items[0])
@@ -806,6 +849,48 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# -------------------------------------------------------------------------
# Retrieve configuration value or return default
def _config(self, key: str, default: any):
return default if self.config[key] is None else self.config[key]
# Retrieve configuration value for table of contents
def _config_toc(self, view: View):
default = self.config.blog_toc
if isinstance(view, Archive):
return self._config("archive_toc", default)
if isinstance(view, Category):
return self._config("categories_toc", default)
if isinstance(view, Profile):
return self._config("authors_profiles_toc", default)
else:
return default
# Retrieve configuration value for pagination
def _config_pagination(self, view: View):
default = self.config.pagination
if isinstance(view, Archive):
return self._config("archive_pagination", default)
if isinstance(view, Category):
return self._config("categories_pagination", default)
if isinstance(view, Profile):
return self._config("authors_profiles_pagination", default)
else:
return default
# Retrieve configuration value for pagination per page
def _config_pagination_per_page(self, view: View):
default = self.config.pagination_per_page
if isinstance(view, Archive):
return self._config("archive_pagination_per_page", default)
if isinstance(view, Category):
return self._config("categories_pagination_per_page", default)
if isinstance(view, Profile):
return self._config("authors_profiles_pagination_per_page", default)
else:
return default
# -------------------------------------------------------------------------
# Format path for post
def _format_path_for_post(self, post: Post, config: MkDocsConfig):
categories = post.config.categories[:self.config.post_url_max_categories]
@@ -845,6 +930,17 @@ class BlogPlugin(BasePlugin[BlogConfig]):
path = posixpath.normpath(path.strip("/"))
return posixpath.join(self.config.blog_dir, f"{path}.md")
# Format path for profile
def _format_path_for_profile(self, id: str, author: Author):
path = self.config.authors_profiles_url_format.format(
slug = author.slug or id,
name = author.name
)
# Normalize path and strip slashes at the beginning and end
path = posixpath.normpath(path.strip("/"))
return posixpath.join(self.config.blog_dir, f"{path}.md")
# Format path for pagination
def _format_path_for_pagination(self, view: View, page: int):
path = self.config.pagination_url_format.format(

View File

@@ -273,6 +273,12 @@ class Category(View):
# -----------------------------------------------------------------------------
# Profile view
class Profile(View):
pass
# -----------------------------------------------------------------------------
# Reference
class Reference(Link):

View File

@@ -33,6 +33,7 @@ class PostConfig(Config):
categories = UniqueListOfItems(Type(str), default = [])
date = PostDate()
draft = Optional(Type(bool))
pin = Type(bool, default = False)
links = Optional(PostLinks())
readtime = Optional(Type(int))
slug = Optional(Type(str))

View File

@@ -36,7 +36,7 @@ from io import BytesIO
from markdown.extensions.toc import slugify
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.plugins import BasePlugin, event_priority
from mkdocs.utils import get_yaml_loader
from mkdocs.utils.yaml import get_yaml_loader
from zipfile import ZipFile, ZIP_DEFLATED
from .config import InfoConfig
@@ -180,7 +180,7 @@ class InfoPlugin(BasePlugin[InfoConfig]):
# Report the invalid paths to the user
if paths_to_validate:
log.error(f"One or more paths aren't children of root")
log.error("One or more paths aren't children of root")
self._help_on_not_in_cwd(paths_to_validate)
# Create in-memory archive and prompt author for a short descriptive

View File

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

View File

@@ -0,0 +1,52 @@
# 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 os
from mkdocs.config.base import Config
from mkdocs.config.config_options import ListOfItems, Type
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Optimize plugin configuration
class OptimizeConfig(Config):
enabled = Type(bool, default = True)
concurrency = Type(int, default = max(1, os.cpu_count() - 1))
# Settings for caching
cache = Type(bool, default = True)
cache_dir = Type(str, default = ".cache/plugin/optimize")
# Settings for optimization
optimize = Type(bool, default = True)
optimize_png = Type(bool, default = True)
optimize_png_speed = Type(int, default = 3)
optimize_png_strip = Type(bool, default = True)
optimize_jpg = Type(bool, default = True)
optimize_jpg_quality = Type(int, default = 60)
optimize_jpg_progressive = Type(bool, default = True)
optimize_include = ListOfItems(Type(str), default = [])
optimize_exclude = ListOfItems(Type(str), default = [])
# Settings for reporting
print_gain = Type(bool, default = True)
print_gain_summary = Type(bool, default = True)

View File

@@ -0,0 +1,388 @@
# 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.
from __future__ import annotations
import functools
import json
import logging
import os
import subprocess
import sys
from fnmatch import fnmatch
from colorama import Fore, Style
from concurrent.futures import Future
from concurrent.futures.thread import ThreadPoolExecutor
from hashlib import sha1
from mkdocs import utils
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin
from mkdocs.structure.files import File
from shutil import which
try:
from PIL import Image
except ImportError:
pass
from .config import OptimizeConfig
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Optimize plugin
class OptimizePlugin(BasePlugin[OptimizeConfig]):
supports_multiple_instances = True
# Manifest
manifest: dict[str, str] = {}
# Initialize plugin
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize incremental builds
self.is_serve = False
# Determine whether we're serving the site
def on_startup(self, *, command, dirty):
self.is_serve = command == "serve"
# Initialize thread pool
self.pool = ThreadPoolExecutor(self.config.concurrency)
self.pool_jobs: dict[str, Future] = {}
# Resolve and load manifest
def on_config(self, config):
if not self.config.enabled:
return
# Resolve cache directory (once) - this is necessary, so the cache is
# always relative to the configuration file, and thus project, and not
# relative to the current working directory, or it would not work with
# the projects plugin.
path = os.path.abspath(self.config.cache_dir)
if path != self.config.cache_dir:
self.config.cache_dir = os.path.join(
os.path.dirname(config.config_file_path),
os.path.normpath(self.config.cache_dir)
)
# Ensure cache directory exists
os.makedirs(self.config.cache_dir, exist_ok = True)
# Initialize manifest
self.manifest_file = os.path.join(
self.config.cache_dir, "manifest.json"
)
# Load manifest if it exists and the cache should be used
if os.path.isfile(self.manifest_file) and self.config.cache:
try:
with open(self.manifest_file) as f:
self.manifest = json.load(f)
except:
pass
# Initialize optimization pipeline
def on_env(self, env, *, config, files):
if not self.config.enabled:
return
# Skip if media files should not be optimized
if not self.config.optimize:
return
# Filter all optimizable media files and steal reponsibility from MkDocs
# by removing them from the files collection. Then, start a concurrent
# job that checks if an image was already optimized and can be returned
# from the cache, or optimize it accordingly.
for file in files.media_files():
if self._is_excluded(file):
continue
# Spawn concurrent job to optimize the given image and add future
# to job dictionary, as it returns the file we need to copy later
path = os.path.join(self.config.cache_dir, file.src_path)
self.pool_jobs[file.abs_src_path] = self.pool.submit(
self._optimize_image, file, path, config
)
# Steal responsibility from MkDocs
files.remove(file)
# Finish optimization pipeline
def on_post_build(self, *, config):
if not self.config.enabled:
return
# Skip if media files should not be optimized
if not self.config.optimize:
return
# Reconcile concurrent jobs - we need to wait for all jobs to finish
# before we can copy the optimized files to the output directory. If an
# exception occurred in one of the jobs, we raise it here, so the build
# fails and the author can fix the issue.
for path, future in self.pool_jobs.items():
if future.exception():
raise future.exception()
else:
file: File = future.result()
file.copy_file()
# Save manifest if cache should be used
if self.config.cache:
with open(self.manifest_file, "w") as f:
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
# Compute and print gains through optimization
if self.config.print_gain_summary:
print(Style.NORMAL)
print(f" Optimizations:")
# Print summary for file extension
for seek in [".png", ".jpg"]:
size = size_opt = 0
for path, future in self.pool_jobs.items():
file: File = future.result()
# Skip files that are not of the given type
_, extension = os.path.splitext(path)
extension = ".jpg" if extension == ".jpeg" else extension
if extension != seek:
continue
# Compute size before and after optimization
size += os.path.getsize(path)
size_opt += os.path.getsize(file.abs_dest_path)
# Compute absolute and relative gain
if size and size_opt:
gain_abs = size - size_opt
gain_rel = (1 - size_opt / size) * 100
# Print summary for files
print(
f" *{seek} {Fore.GREEN}{_size(size_opt)}"
f"{Fore.WHITE}{Style.DIM}"
f"{_size(gain_abs)} [{gain_rel:3.1f}%]"
f"{Style.RESET_ALL}"
)
# Reset all styles
print(Style.RESET_ALL)
# Save manifest on shutdown
def on_shutdown(self):
if not self.config.enabled:
return
# Shutdown thread pool - if we're on Python 3.9 and above, cancel all
# pending futures that have not yet been scheduled
if sys.version_info >= (3, 9):
self.pool.shutdown(cancel_futures = True)
else:
self.pool.shutdown()
# Save manifest if cache should be used
if self.manifest and self.config.cache:
with open(self.manifest_file, "w") as f:
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
# -------------------------------------------------------------------------
# Check if a file can be optimized
def _is_optimizable(self, file: File):
# Check if PNG images should be optimized
if file.url.endswith((".png")):
return self.config.optimize_png
# Check if JPG images should be optimized
if file.url.endswith((".jpg", ".jpeg")):
return self.config.optimize_jpg
# File can not be optimized by the plugin
return False
# Check if the given file is excluded
def _is_excluded(self, file: File):
if not self._is_optimizable(file):
return True
# Check if file matches one of the inclusion patterns
path = file.src_path
if self.config.optimize_include:
for pattern in self.config.optimize_include:
if fnmatch(file.src_uri, pattern):
return False
# File is not included
log.debug(f"Excluding file '{path}' due to inclusion patterns")
return True
# Check if file matches one of the exclusion patterns
for pattern in self.config.optimize_exclude:
if fnmatch(file.src_uri, pattern):
log.debug(f"Excluding file '{path}' due to exclusion patterns")
return True
# File is not excluded
return False
# Optimize image and write to cache
def _optimize_image(self, file: File, path: str, config: MkDocsConfig):
with open(file.abs_src_path, "rb") as f:
data = f.read()
hash = sha1(data).hexdigest()
# Check if file hash changed, so we need to optimize again
prev = self.manifest.get(file.url, "")
if hash != prev or not os.path.isfile(path):
os.makedirs(os.path.dirname(path), exist_ok = True)
# Optimize PNG image using pngquant
if file.url.endswith((".png")):
self._optimize_image_png(file, path, config)
# Optimize JPG image using pillow
if file.url.endswith((".jpg", ".jpeg")):
self._optimize_image_jpg(file, path, config)
# Compute size before and after optimization
size = len(data)
size_opt = os.path.getsize(path)
# Compute absolute and relative gain
gain_abs = size - size_opt
gain_rel = (1 - size_opt / size) * 100
# Print how much we gained, if we did and desired
gain = ""
if gain_abs and self.config.print_gain:
gain += ""
gain += " ".join([_size(gain_abs), f"[{gain_rel:3.1f}%]"])
# Print summary for file
log.info(
f"Optimized media file: {file.src_uri} "
f"{Fore.GREEN}{_size(size_opt)}"
f"{Fore.WHITE}{Style.DIM}{gain}"
f"{Style.RESET_ALL}"
)
# Update manifest by associating file with hash
self.manifest[file.url] = hash
# Compute project root
root = os.path.dirname(config.config_file_path)
# Compute source file system path
file.abs_src_path = path
file.src_path = os.path.relpath(path, root)
# Return file to be copied from cache
return file
# Optimize PNG image - we first tried to use libimagequant, but encountered
# the occassional segmentation fault, which means it's probably not a good
# choice. Instead, we just rely on pngquant which seems much more stable.
def _optimize_image_png(self, file: File, path: str, config: MkDocsConfig):
# Check if the required dependencies for optimizing are available, which
# is, at the absolute minimum, the 'pngquant' binary, and raise an error
# to the caller, so he can decide what to do with the error. The caller
# can treat this as a warning or an error to abort the build.
if not which("pngquant"):
docs = os.path.relpath(config.docs_dir)
path = os.path.relpath(file.abs_src_path, docs)
raise PluginError(
f"Couldn't optimize image '{path}' in '{docs}': 'pngquant' "
f"not found. Make sure 'pngquant' is installed and in your path"
)
# Build command line arguments
args = ["pngquant",
"--force", "--skip-if-larger",
"--output", path,
"--speed", f"{self.config.optimize_png_speed}"
]
# Add flag to remove optional metadata
if self.config.optimize_png_strip:
args.append("--strip")
# Set input file and run, then check if pngquant actually wrote a file,
# as we instruct it not to if the size of the optimized file is larger.
# This can happen if files are already compressed and optimized by
# the author. In that case, just copy the original file.
subprocess.run([*args, file.abs_src_path])
if not os.path.isfile(path):
utils.copy_file(file.abs_src_path, path)
# Optimize JPG image
def _optimize_image_jpg(self, file: File, path: str, config: MkDocsConfig):
# Check if the required dependencies for optimizing are available, which
# is, at the absolute minimum, the 'pillow' package, and raise an error
# to the caller, so he can decide what to do with the error. The caller
# can treat this as a warning or an error to abort the build.
if not _supports("Image"):
docs = os.path.relpath(config.docs_dir)
path = os.path.relpath(file.abs_src_path, docs)
raise PluginError(
f"Couldn't optimize image '{path}' in '{docs}': install "
f"required dependencies pip install 'mkdocs-material[imaging]'"
)
# Open and save optimized image
image = Image.open(file.abs_src_path)
image.save(path, "jpeg",
quality = self.config.optimize_jpg_quality,
progressive = self.config.optimize_jpg_progressive
)
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
# Check for presence of optional imports
@functools.lru_cache(maxsize = None)
def _supports(name: str):
return name in globals()
# -----------------------------------------------------------------------------
# Print human-readable size
def _size(value):
for unit in ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB"]:
if abs(value) < 1000.0:
return f"{value:3.1f} {unit}"
value /= 1000.0
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.optimize")

View File

@@ -21,7 +21,21 @@
import os
from mkdocs.config.base import Config
from mkdocs.config.config_options import DictOfItems, Type
from mkdocs.config.config_options import (
Choice, Deprecated, DictOfItems, ListOfItems, Type
)
# -----------------------------------------------------------------------------
# Options
# -----------------------------------------------------------------------------
# Options for log level
LogLevel = (
"error",
"warn",
"info",
"debug"
)
# -----------------------------------------------------------------------------
# Classes
@@ -36,8 +50,29 @@ class PrivacyConfig(Config):
cache = Type(bool, default = True)
cache_dir = Type(str, default = ".cache/plugin/privacy")
# Settings for logging
log = Type(bool, default = True)
log_level = Choice(LogLevel, default = "info")
# Settings for external assets
assets = Type(bool, default = True)
assets_fetch = Type(bool, default = True)
assets_fetch_dir = Type(str, default = "assets/external")
assets_include = ListOfItems(Type(str), default = [])
assets_exclude = ListOfItems(Type(str), default = [])
assets_expr_map = DictOfItems(Type(str), default = {})
# Settings for external links
links = Type(bool, default = True)
links_attr_map = DictOfItems(Type(str), default = {})
links_noopener = Type(bool, default = True)
# Deprecated settings
external_assets = Deprecated(message = "Deprecated, use 'assets_fetch'")
external_assets_dir = Deprecated(moved_to = "assets_fetch_dir")
external_assets_include = Deprecated(moved_to = "assets_include")
external_assets_exclude = Deprecated(moved_to = "assets_exclude")
external_assets_expr = Deprecated(moved_to = "assets_expr_map")
external_links = Deprecated(moved_to = "links")
external_links_attr_map = Deprecated(moved_to = "links_attr_map")
external_links_noopener = Deprecated(moved_to = "links_noopener")

View File

@@ -29,7 +29,9 @@ import requests
import sys
from colorama import Fore, Style
from concurrent.futures import Future, ThreadPoolExecutor, wait
from concurrent.futures import Future, wait
from concurrent.futures.thread import ThreadPoolExecutor
from fnmatch import fnmatch
from hashlib import sha1
from mkdocs.config.config_options import ExtraScriptValue
from mkdocs.config.defaults import MkDocsConfig
@@ -52,6 +54,7 @@ DEFAULT_TIMEOUT_IN_SECS = 5
# Privacy plugin
class PrivacyPlugin(BasePlugin[PrivacyConfig]):
supports_multiple_instances = True
# Initialize thread pools and asset collections
def on_config(self, config):
@@ -65,12 +68,20 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
# Initialize collections of external assets
self.assets = Files([])
self.assets_done: list[File] = []
self.assets_expr_map = {
".css": r"url\(\s*([\"']?)(?P<url>http?[^)'\"]+)\1\s*\)",
".js": r"[\"'](?P<url>http[^\"']+\.(?:css|js(?:on)?))[\"']",
**self.config.assets_expr_map
}
# Set log level or disable logging altogether - @todo when refactoring
# this plugin for the next time, we should put this into a factory
if not self.config.log:
log.disabled = True
else:
log.setLevel(self.config.log_level.upper())
# Process external style sheets and scripts (run latest) - run this after
# all other plugins, so they can add additional assets
@event_priority(-100)
@@ -127,7 +138,13 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
# Process external images in page (run latest) - this stage is the earliest
# we can start processing external images, since images are the most common
# type of external asset when writing. Thus, we create and enqueue a job for
# each image we find that checks if the image needs to be downloaded.
# each image we find that checks if the image needs to be downloaded. Also,
# downloading all external images at this stage, we reconcile all concurrent
# jobs in `on_env`, which is the stage in which the optimize plugin will
# evaluate what images can and need to be optimized. This means we can pass
# external images through the optimization pipeline. Additionally, we run
# this after all other plugins, so we allow them to add additional images
# to the content of the page. How cool is that?
@event_priority(-100)
def on_page_content(self, html, *, page, config, files):
if not self.config.enabled:
@@ -149,13 +166,27 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
if not self._is_excluded(url, page.file):
self._queue(url, config, concurrent = True)
# Sync all concurrent jobs
# Reconcile jobs and pass external assets to MkDocs (run earlier) - allow
# other plugins (e.g. optimize plugin) to post-process external assets
@event_priority(50)
def on_env(self, env, *, config, files):
if not self.config.enabled:
return
# Wait until all jobs until now are finished
# Reconcile concurrent jobs and clear thread pool, as we will reuse the
# same thread pool for fetching all remaining external assets
wait(self.pool_jobs)
self.pool_jobs.clear()
# Append all downloaded assets that are not style sheets or scripts to
# MkDocs's collection of files, making them available to other plugins
# for further processing. The remaining exteral assets are patched
# before copying, which is done at the end of the build process.
for file in self.assets:
_, extension = posixpath.splitext(file.dest_uri)
if extension not in [".css", ".js"]:
self.assets_done.append(file)
files.append(file)
# Process external assets in template (run later)
@event_priority(-50)
@@ -180,7 +211,8 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
# Parse and replace links to external assets
return self._parse_html(output, page.file, config)
# Reconcile jobs (run earlier)
# Reconcile jobs (run earlier) - allow other plugins (e.g. optimize plugin)
# to process all downloaded assets, which is why we must reconcile here
@event_priority(50)
def on_post_build(self, *, config):
if not self.config.enabled:
@@ -200,10 +232,10 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
self._patch, file
))
# Otherwise just copy external asset to output directory if it
# exists, i.e., if the download succeeded
else:
if os.path.exists(file.abs_src_path):
# Otherwise just copy external asset to output directory, if we
# haven't handed control to MkDocs in `on_env` before
elif file not in self.assets_done:
if os.path.exists(str(file.abs_src_path)):
file.copy_file()
# Reconcile concurrent jobs for the last time, so the plugins following
@@ -236,6 +268,28 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
Style.RESET_ALL
])
# Check if URL matches one of the inclusion patterns
if self.config.assets_include:
for pattern in self.config.assets_include:
if fnmatch(self._path_from_url(url), pattern):
return False
# File is not included
log.debug(
f"Excluding external file '{url.geturl()}' {via}due to "
f"inclusion patterns"
)
return True
# Check if URL matches one of the exclusion patterns
for pattern in self.config.assets_exclude:
if fnmatch(self._path_from_url(url), pattern):
log.debug(
f"Excluding external file '{url.geturl()}' {via}due to "
f"exclusion patterns"
)
return True
# Print warning if fetching is not enabled
if not self.config.assets_fetch:
log.warning(f"External file: {url.geturl()} {via}")
@@ -301,6 +355,21 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
def replace(match: Match):
el = self._parse_fragment(match.group())
# Handle external link
if self.config.links and el.tag == "a":
for key, value in self.config.links_attr_map.items():
el.set(key, value)
# Set `rel=noopener` if link opens in a new window
if self.config.links_noopener:
if el.get("target") == "_blank":
rel = re.findall(r"\S+", el.get("rel", ""))
if "noopener" not in rel:
rel.append("noopener")
# Set relationships after adding `noopener`
el.set("rel", " ".join(rel))
# Handle external style sheet or preconnect hint
if el.tag == "link":
url = urlparse(el.get("href"))

View File

@@ -39,6 +39,10 @@ pipeline = ("stemmer", "stopWordFilter", "trimmer")
# Classes
# -----------------------------------------------------------------------------
# Search field configuration
class SearchFieldConfig(Config):
boost = Type((int, float), default = 1.0)
# Search plugin configuration
class SearchConfig(Config):
enabled = Type(bool, default = True)
@@ -47,6 +51,7 @@ class SearchConfig(Config):
lang = Optional(LangOption())
separator = Optional(Type(str))
pipeline = Optional(ListOfItems(Choice(pipeline)))
fields = Type(dict, default = {})
# Settings for text segmentation (Chinese)
jieba_dict = Optional(Type(str))

View File

@@ -27,9 +27,10 @@ from backrefs import bre
from html import escape
from html.parser import HTMLParser
from mkdocs import utils
from mkdocs.config.config_options import SubConfig
from mkdocs.plugins import BasePlugin
from .config import SearchConfig
from .config import SearchConfig, SearchFieldConfig
try:
import jieba
@@ -81,6 +82,19 @@ class SearchPlugin(BasePlugin[SearchConfig]):
r"\s*,\s*", self._translate(config, "search.config.pipeline")
)))
# Validate field configuration
validator = SubConfig(SearchFieldConfig)
for config in self.config.fields.values():
validator.run_validation(config)
# Merge with default fields
if "title" not in self.config.fields:
self.config.fields["title"] = { "boost": 1e3 }
if "text" not in self.config.fields:
self.config.fields["text"] = { "boost": 1e0 }
if "tags" not in self.config.fields:
self.config.fields["tags"] = { "boost": 1e6 }
# Initialize search index
self.search_index = SearchIndex(**self.config)
@@ -230,7 +244,7 @@ class SearchIndex:
def generate_search_index(self, prev):
config = {
key: self.config[key]
for key in ["lang", "separator", "pipeline"]
for key in ["lang", "separator", "pipeline", "fields"]
}
# Hack: if we're running under dirty reload, the search index will only

View File

@@ -18,8 +18,11 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
import os
from mkdocs.config.base import Config
from mkdocs.config.config_options import Deprecated, Type
from mkdocs.config.config_options import Deprecated, ListOfItems, Type
from mkdocs.config.defaults import _LogLevel
# -----------------------------------------------------------------------------
# Classes
@@ -28,21 +31,38 @@ from mkdocs.config.config_options import Deprecated, Type
# Social plugin configuration
class SocialConfig(Config):
enabled = Type(bool, default = True)
concurrency = Type(int, default = max(1, os.cpu_count() - 1))
# Settings for caching
cache = Type(bool, default = True)
cache_dir = Type(str, default = ".cache/plugin/social")
# Settings for social cards
# Settings for logging
log = Type(bool, default = True)
log_level = _LogLevel(default = "warn")
# Settings for cards
cards = Type(bool, default = True)
cards_dir = Type(str, default = "assets/images/social")
cards_layout_dir = Type(str, default = "layouts")
cards_layout = Type(str, default = "default")
cards_layout_options = Type(dict, default = {})
cards_include = ListOfItems(Type(str), default = [])
cards_exclude = ListOfItems(Type(str), default = [])
# Settings for debugging
debug = Type(bool, default = False)
debug_on_build = Type(bool, default = False)
debug_grid = Type(bool, default = True)
debug_grid_step = Type(int, default = 32)
debug_color = Type(str, default = "grey")
# Deprecated settings
cards_color = Deprecated(
option_type = Type(dict, default = {}),
message =
"Deprecated, use 'cards_layout_options.background_color' "
"and 'cards_layout_options.color' with 'default' layout"
)
)
cards_font = Deprecated(
option_type = Type(str),
message = "Deprecated, use 'cards_layout_options.font_family'"
)

View File

@@ -0,0 +1,153 @@
# 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.
from __future__ import annotations
import re
from mkdocs.config.base import Config
from mkdocs.config.config_options import (
Choice, DictOfItems, ListOfItems, SubConfig, Type
)
try:
from PIL.Image import Image as _Image
except ImportError:
pass
# -----------------------------------------------------------------------------
# Options
# -----------------------------------------------------------------------------
# Options for origin
Origin = (
"start top", "center top", "end top",
"start center", "center", "end center",
"start bottom", "center bottom", "end bottom",
"start", "end"
)
# Options for overflow
Overflow = (
"truncate",
"shrink"
)
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Size
class Size(Config):
width = Type(int, default = 0)
height = Type(int, default = 0)
# Offset
class Offset(Config):
x = Type(int, default = 0)
y = Type(int, default = 0)
# # -----------------------------------------------------------------------------
# Background
class Background(Config):
color = Type(str, default = "")
image = Type(str, default = "")
# # -----------------------------------------------------------------------------
# Icon
class Icon(Config):
value = Type(str, default = "")
color = Type(str, default = "")
# # -----------------------------------------------------------------------------
# Line
class Line(Config):
amount = Type((int, float), default = 1)
height = Type((int, float), default = 1)
# Font
class Font(Config):
family = Type(str, default = "Roboto")
variant = Type(str, default = "")
style = Type(str, default = "Regular")
# Typography
class Typography(Config):
content = Type(str, default = "")
align = Choice(Origin, default = "start top")
overflow = Choice(Overflow, default = "truncate")
color = Type(str, default = "")
line = SubConfig(Line)
font = SubConfig(Font)
# -----------------------------------------------------------------------------
# Layer
class Layer(Config):
size = SubConfig(Size)
offset = SubConfig(Offset)
origin = Choice(Origin, default = "start top")
background = SubConfig(Background)
icon = SubConfig(Icon)
typography = SubConfig(Typography)
# -----------------------------------------------------------------------------
# Layout
class Layout(Config):
definitions = ListOfItems(Type(str), default = [])
tags = DictOfItems(Type(str), default = {})
size = SubConfig(Size)
layers = ListOfItems(SubConfig(Layer), default = [])
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Get layer or layout size as tuple
def get_size(layer: Layer | Layout):
return layer.size.width, layer.size.height
# Get layer offset as tuple
def get_offset(layer: Layer, image: _Image):
x, y = layer.offset.x, layer.offset.y
# Compute offset from origin - if an origin is given, compute the offset
# relative to the image and layer size to allow for flexible positioning
if layer.origin != "start top":
origin = re.split(r"\s+", layer.origin)
# Get layer size
w, h = get_size(layer)
# Compute origin on x-axis
if "start" in origin: pass
elif "end" in origin: x += (image.width - w) - 2 * x
elif "center" in origin: x += (image.width - w) >> 1
# Compute origin on y-axis
if "top" in origin: pass
elif "bottom" in origin: y += (image.height - h) - 2 * y
elif "center" in origin: y += (image.height - h) >> 1
# Return offset
return x, y

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
# 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.
from __future__ import annotations
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Filter for coercing everthing that is falsy to an empty string
def x_filter(value: str | None):
return value or ""

View File

@@ -0,0 +1,244 @@
# 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.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image | x }}
# Background color (default: indigo)
- &background_color >-
{%- if layout.background_color -%}
{{ layout.background_color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ef5552",
"pink": "#e92063",
"purple": "#ab47bd",
"deep-purple": "#7e56c2",
"indigo": "#4051b5",
"blue": "#2094f3",
"light-blue": "#02a6f2",
"cyan": "#00bdd6",
"teal": "#009485",
"green": "#4cae4f",
"light-green": "#8bc34b",
"lime": "#cbdc38",
"yellow": "#ffec3d",
"amber": "#ffc105",
"orange": "#ffa724",
"deep-orange": "#ff6e42",
"brown": "#795649",
"grey": "#757575",
"blue-grey": "#546d78",
"black": "#000000",
"white": "#ffffff"
}[primary] or "#4051b5" }}
{%- endif -%}
# Text color (default: white)
- &color >-
{%- if layout.color -%}
{{ layout.color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ffffff",
"pink": "#ffffff",
"purple": "#ffffff",
"deep-purple": "#ffffff",
"indigo": "#ffffff",
"blue": "#ffffff",
"light-blue": "#ffffff",
"cyan": "#ffffff",
"teal": "#ffffff",
"green": "#ffffff",
"light-green": "#ffffff",
"lime": "#000000",
"yellow": "#000000",
"amber": "#000000",
"orange": "#000000",
"deep-orange": "#ffffff",
"brown": "#ffffff",
"grey": "#ffffff",
"blue-grey": "#ffffff",
"black": "#ffffff",
"white": "#000000"
}[primary] or "#ffffff" }}
{%- endif -%}
# Font family (default: Roboto)
- &font_family >-
{%- if layout.font_family -%}
{{ layout.font_family }}
{%- elif config.theme.font is mapping -%}
{{ config.theme.font.get("text", "Roboto") }}
{%- else -%}
Roboto
{%- endif -%}
# Font variant
- &font_variant >-
{%- if layout.font_variant -%}
{{ layout.font_variant }}
{%- endif -%}
# Site name
- &site_name >-
{{ config.site_name }}
# Page title
- &page_title >-
{%- if layout.title -%}
{{ layout.title }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ config.site_name }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Logo
- &logo >-
{%- if layout.logo -%}
{{ layout.logo }}
{%- elif config.theme.logo -%}
{{ config.docs_dir }}/{{ config.theme.logo }}
{%- endif -%}
# Logo (icon)
- &logo_icon >-
{%- if not layout.logo and config.theme.icon -%}
{{ config.theme.icon.logo | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image
color: *background_color
# Logo
- size: { width: 144, height: 144 }
offset: { x: 992, y: 64 }
background:
image: *logo
icon:
value: *logo_icon
color: *color
# Site name
- size: { width: 832, height: 42 }
offset: { x: 64, y: 64 }
typography:
content: *site_name
color: *color
font:
family: *font_family
variant: *font_variant
style: Bold
# Page title
- size: { width: 832, height: 310 }
offset: { x: 62, y: 160 }
typography:
content: *page_title
align: start
color: *color
line:
amount: 3
height: 1.25
font:
family: *font_family
variant: *font_variant
style: Bold
# Page description
- size: { width: 832, height: 64 }
offset: { x: 64, y: 512 }
typography:
content: *page_description
align: start
color: *color
line:
amount: 2
height: 1.5
font:
family: *font_family
variant: *font_variant
style: Regular

View File

@@ -0,0 +1,234 @@
# 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.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image | x }}
# Background color (default: indigo)
- &background_color >-
{%- if layout.background_color -%}
{{ layout.background_color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("accent") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set accent = palette.get("accent", "indigo") -%}
{%- set accent = accent.replace(" ", "-") -%}
{{ {
"red": "#ff1a47",
"pink": "#f50056",
"purple": "#df41fb",
"deep-purple": "#7c4dff",
"indigo": "#526cfe",
"blue": "#4287ff",
"light-blue": "#0091eb",
"cyan": "#00bad6",
"teal": "#00bda4",
"green": "#00c753",
"light-green": "#63de17",
"lime": "#b0eb00",
"yellow": "#ffd500",
"amber": "#ffaa00",
"orange": "#ff9100",
"deep-orange": "#ff6e42"
}[accent] or "#4051b5" }}
{%- endif -%}
# Text color (default: white)
- &color >-
{%- if layout.color -%}
{{ layout.color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("accent") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set accent = palette.get("accent", "indigo") -%}
{%- set accent = accent.replace(" ", "-") -%}
{{ {
"red": "#ffffff",
"pink": "#ffffff",
"purple": "#ffffff",
"deep-purple": "#ffffff",
"indigo": "#ffffff",
"blue": "#ffffff",
"light-blue": "#ffffff",
"cyan": "#ffffff",
"teal": "#ffffff",
"green": "#ffffff",
"light-green": "#ffffff",
"lime": "#000000",
"yellow": "#000000",
"amber": "#000000",
"orange": "#000000",
"deep-orange": "#ffffff"
}[accent] or "#ffffff" }}
{%- endif -%}
# Font family (default: Roboto)
- &font_family >-
{%- if layout.font_family -%}
{{ layout.font_family }}
{%- elif config.theme.font is mapping -%}
{{ config.theme.font.get("text", "Roboto") }}
{%- else -%}
Roboto
{%- endif -%}
# Font variant
- &font_variant >-
{%- if layout.font_variant -%}
{{ layout.font_variant }}
{%- endif -%}
# Site name
- &site_name >-
{{ config.site_name }}
# Page title
- &page_title >-
{%- if layout.title -%}
{{ layout.title }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ config.site_name }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Logo
- &logo >-
{%- if layout.logo -%}
{{ layout.logo }}
{%- elif config.theme.logo -%}
{{ config.docs_dir }}/{{ config.theme.logo }}
{%- endif -%}
# Logo (icon)
- &logo_icon >-
{%- if not layout.logo and config.theme.icon -%}
{{ config.theme.icon.logo | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image
color: *background_color
# Logo
- size: { width: 144, height: 144 }
offset: { x: 992, y: 64 }
background:
image: *logo
icon:
value: *logo_icon
color: *color
# Site name
- size: { width: 832, height: 42 }
offset: { x: 64, y: 64 }
typography:
content: *site_name
color: *color
font:
family: *font_family
variant: *font_variant
style: Bold
# Page title
- size: { width: 832, height: 310 }
offset: { x: 62, y: 160 }
typography:
content: *page_title
align: start
color: *color
line:
amount: 3
height: 1.25
font:
family: *font_family
variant: *font_variant
style: Bold
# Page description
- size: { width: 832, height: 64 }
offset: { x: 64, y: 512 }
typography:
content: *page_description
align: start
color: *color
line:
amount: 2
height: 1.5
font:
family: *font_family
variant: *font_variant
style: Regular

View File

@@ -0,0 +1,244 @@
# 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.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image | x }}
# Background color (default: white)
- &background_color >-
{%- if layout.background_color -%}
{{ layout.background_color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ffffff",
"pink": "#ffffff",
"purple": "#ffffff",
"deep-purple": "#ffffff",
"indigo": "#ffffff",
"blue": "#ffffff",
"light-blue": "#ffffff",
"cyan": "#ffffff",
"teal": "#ffffff",
"green": "#ffffff",
"light-green": "#ffffff",
"lime": "#000000",
"yellow": "#000000",
"amber": "#000000",
"orange": "#000000",
"deep-orange": "#ffffff",
"brown": "#ffffff",
"grey": "#ffffff",
"blue-grey": "#ffffff",
"black": "#ffffff",
"white": "#000000"
}[primary] or "#ffffff" }}
{%- endif -%}
# Text color (default: indigo)
- &color >-
{%- if layout.color -%}
{{ layout.color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ef5552",
"pink": "#e92063",
"purple": "#ab47bd",
"deep-purple": "#7e56c2",
"indigo": "#4051b5",
"blue": "#2094f3",
"light-blue": "#02a6f2",
"cyan": "#00bdd6",
"teal": "#009485",
"green": "#4cae4f",
"light-green": "#8bc34b",
"lime": "#cbdc38",
"yellow": "#ffec3d",
"amber": "#ffc105",
"orange": "#ffa724",
"deep-orange": "#ff6e42",
"brown": "#795649",
"grey": "#757575",
"blue-grey": "#546d78",
"black": "#000000",
"white": "#ffffff"
}[primary] or "#4051b5" }}
{%- endif -%}
# Font family (default: Roboto)
- &font_family >-
{%- if layout.font_family -%}
{{ layout.font_family }}
{%- elif config.theme.font is mapping -%}
{{ config.theme.font.get("text", "Roboto") }}
{%- else -%}
Roboto
{%- endif -%}
# Font variant
- &font_variant >-
{%- if layout.font_variant -%}
{{ layout.font_variant }}
{%- endif -%}
# Site name
- &site_name >-
{{ config.site_name }}
# Page title
- &page_title >-
{%- if layout.title -%}
{{ layout.title }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ config.site_name }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Logo
- &logo >-
{%- if layout.logo -%}
{{ layout.logo }}
{%- elif config.theme.logo -%}
{{ config.docs_dir }}/{{ config.theme.logo }}
{%- endif -%}
# Logo (icon)
- &logo_icon >-
{%- if not layout.logo and config.theme.icon -%}
{{ config.theme.icon.logo | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image
color: *background_color
# Logo
- size: { width: 144, height: 144 }
offset: { x: 992, y: 64 }
background:
image: *logo
icon:
value: *logo_icon
color: *color
# Site name
- size: { width: 832, height: 42 }
offset: { x: 64, y: 64 }
typography:
content: *site_name
color: *color
font:
family: *font_family
variant: *font_variant
style: Bold
# Page title
- size: { width: 832, height: 310 }
offset: { x: 62, y: 160 }
typography:
content: *page_title
align: start
color: *color
line:
amount: 3
height: 1.25
font:
family: *font_family
variant: *font_variant
style: Bold
# Page description
- size: { width: 832, height: 64 }
offset: { x: 64, y: 512 }
typography:
content: *page_description
align: start
color: *color
line:
amount: 2
height: 1.5
font:
family: *font_family
variant: *font_variant
style: Regular

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.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image }}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image

View File

@@ -0,0 +1,255 @@
# 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.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image | x }}
# Background color (default: indigo)
- &background_color >-
{%- if layout.background_color -%}
{{ layout.background_color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ef5552",
"pink": "#e92063",
"purple": "#ab47bd",
"deep-purple": "#7e56c2",
"indigo": "#4051b5",
"blue": "#2094f3",
"light-blue": "#02a6f2",
"cyan": "#00bdd6",
"teal": "#009485",
"green": "#4cae4f",
"light-green": "#8bc34b",
"lime": "#cbdc38",
"yellow": "#ffec3d",
"amber": "#ffc105",
"orange": "#ffa724",
"deep-orange": "#ff6e42",
"brown": "#795649",
"grey": "#757575",
"blue-grey": "#546d78",
"black": "#000000",
"white": "#ffffff"
}[primary] or "#4051b5" }}
{%- endif -%}
# Text color (default: white)
- &color >-
{%- if layout.color -%}
{{ layout.color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ffffff",
"pink": "#ffffff",
"purple": "#ffffff",
"deep-purple": "#ffffff",
"indigo": "#ffffff",
"blue": "#ffffff",
"light-blue": "#ffffff",
"cyan": "#ffffff",
"teal": "#ffffff",
"green": "#ffffff",
"light-green": "#ffffff",
"lime": "#000000",
"yellow": "#000000",
"amber": "#000000",
"orange": "#000000",
"deep-orange": "#ffffff",
"brown": "#ffffff",
"grey": "#ffffff",
"blue-grey": "#ffffff",
"black": "#ffffff",
"white": "#000000"
}[primary] or "#ffffff" }}
{%- endif -%}
# Font family (default: Roboto)
- &font_family >-
{%- if layout.font_family -%}
{{ layout.font_family }}
{%- elif config.theme.font is mapping -%}
{{ config.theme.font.get("text", "Roboto") }}
{%- else -%}
Roboto
{%- endif -%}
# Font variant
- &font_variant >-
{%- if layout.font_variant -%}
{{ layout.font_variant }}
{%- endif -%}
# Site name
- &site_name >-
{{ config.site_name }}
# Page title
- &page_title >-
{%- if layout.title -%}
{{ layout.title }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ config.site_name }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Page icon
- &page_icon >-
{{ page.meta.icon | x }}
# Logo
- &logo >-
{%- if layout.logo -%}
{{ layout.logo }}
{%- elif config.theme.logo -%}
{{ config.docs_dir }}/{{ config.theme.logo }}
{%- endif -%}
# Logo (icon)
- &logo_icon >-
{%- if not layout.logo and config.theme.icon -%}
{{ config.theme.icon.logo | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image
color: *background_color
# Page icon
- size: { width: 630, height: 630 }
offset: { x: 800, y: 0 }
icon:
value: *page_icon
color: "#00000033"
# Logo
- size: { width: 64, height: 64 }
offset: { x: 64, y: 64 }
background:
image: *logo
icon:
value: *logo_icon
color: *color
# Site name
- size: { width: 768, height: 42 }
offset: { x: 160, y: 74 }
typography:
content: *site_name
color: *color
font:
family: *font_family
variant: *font_variant
style: Bold
# Page title
- size: { width: 864, height: 256 }
offset: { x: 62, y: 192 }
typography:
content: *page_title
align: start
color: *color
line:
amount: 3
height: 1.25
font:
family: *font_family
variant: *font_variant
style: Bold
# Page description
- size: { width: 864, height: 64 }
offset: { x: 64, y: 512 }
typography:
content: *page_description
align: start
color: *color
line:
amount: 2
height: 1.5
font:
family: *font_family
variant: *font_variant
style: Regular

View File

@@ -46,6 +46,8 @@ class TagsConfig(Config):
tags_slugify = Type(Callable, default = slugify(case = "lower"))
tags_slugify_separator = Type(str, default = "-")
tags_slugify_format = Type(str, default = "tag:{slug}")
tags_hierarchy = Type(bool, default = False)
tags_hierarchy_separator = Type(str, default = "/")
tags_sort_by = Type(Callable, default = tag_name)
tags_sort_reverse = Type(bool, default = False)
tags_name_property = Type(str, default = "tags")
@@ -61,13 +63,26 @@ class TagsConfig(Config):
listings_tags_sort_reverse = Type(bool, default = False)
listings_directive = Type(str, default = "material/tags")
listings_layout = Type(str, default = "default")
listings_toc = Type(bool, default = True)
# Settings for shadow tags
shadow = Type(bool, default = False)
shadow_on_serve = Type(bool, default = True)
shadow_tags = TagSet()
shadow_tags_prefix = Type(str, default = "")
shadow_tags_suffix = Type(str, default = "")
# Settings for export
export = Type(bool, default = True)
export_file = Type(str, default = "tags.json")
export_only = Type(bool, default = False)
# Deprecated settings
tags_compare = Deprecated(moved_to = "tags_sort_by")
tags_compare_reverse = Deprecated(moved_to = "tags_sort_reverse")
tags_pages_compare = Deprecated(moved_to = "listings_sort_by")
tags_pages_compare_reverse = Deprecated(moved_to = "listings_sort_reverse")
tags_file = Deprecated(
option_type = Type(str),
message = "This setting is not required anymore"
tags_file = Deprecated(option_type = Type(str))
tags_extra_files = Deprecated(
option_type = DictOfItems(ListOfItems(Type(str)), default = {})
)

View File

@@ -36,6 +36,7 @@ from .config import TagsConfig
from .renderer import Renderer
from .structure.listing.manager import ListingManager
from .structure.mapping.manager import MappingManager
from .structure.mapping.storage import MappingStorage
# -----------------------------------------------------------------------------
# Classes
@@ -44,6 +45,11 @@ from .structure.mapping.manager import MappingManager
class TagsPlugin(BasePlugin[TagsConfig]):
"""
A tags plugin.
This plugin collects tags from the front matter of pages, and builds a tag
structure from them. The tag structure can be used to render listings on
pages, or to just create a site-wide tags index and export all tags and
mappings to a JSON file for consumption in another project.
"""
supports_multiple_instances = True
@@ -123,6 +129,17 @@ class TagsPlugin(BasePlugin[TagsConfig]):
else:
config.markdown_extensions.append("attr_list")
# If the author only wants to extract and export mappings, we allow to
# disable the rendering of all tags and listings with a single setting
if self.config.export_only:
self.config.tags = False
self.config.listings = False
# By default, shadow tags are rendered when the documentation is served,
# but not when it is built, for a better user experience
if self.is_serve and self.config.shadow_on_serve:
self.config.shadow = True
@event_priority(-50)
def on_page_markdown(
self, markdown: str, *, page: Page, config: MkDocsConfig, **kwargs
@@ -151,6 +168,10 @@ class TagsPlugin(BasePlugin[TagsConfig]):
if self.config.tags_file:
markdown = self._handle_deprecated_tags_file(page, markdown)
# Handle deprecation of `tags_extra_files` setting
if self.config.tags_extra_files:
markdown = self._handle_deprecated_tags_extra_files(page, markdown)
# Collect tags from page
try:
self.mappings.add(page, markdown)
@@ -186,6 +207,15 @@ class TagsPlugin(BasePlugin[TagsConfig]):
# Populate and render all listings
self.listings.populate_all(self.mappings, Renderer(env, config))
# Export mappings to file, if enabled
if self.config.export:
path = os.path.join(config.site_dir, self.config.export_file)
path = os.path.normpath(path)
# Serialize mappings and save to file
storage = MappingStorage(self.config)
storage.save(path, self.mappings)
def on_page_context(
self, context: TemplateContext, *, page: Page, **kwargs
) -> None:
@@ -243,6 +273,38 @@ class TagsPlugin(BasePlugin[TagsConfig]):
# Return markdown
return markdown
def _handle_deprecated_tags_extra_files(
self, page: Page, markdown: str
) -> str:
"""
Handle deprecation of `tags_extra_files` setting.
Arguments:
page: The page.
"""
directive = self.config.listings_directive
if page.file.src_uri not in self.config.tags_extra_files:
return markdown
# Compute tags to render on page
tags = self.config.tags_extra_files[page.file.src_uri]
if tags:
directive += f" {{ include: [{', '.join(tags)}] }}"
# Try to find the legacy tags marker and replace with directive
if "[TAGS]" in markdown:
markdown = markdown.replace(
"[TAGS]", f"<!-- {directive} -->"
)
# Try to find the directive and add it if not present
pattern = r"<!--\s+{directive}".format(directive = re.escape(directive))
if not re.search(pattern, markdown):
markdown += f"\n<!-- {directive} -->"
# Return markdown
return markdown
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------

View File

@@ -192,6 +192,11 @@ class Listing:
"""
Add mapping to listing.
Mappings are only added to listings, if the listing features tags that
are also featured in the mapping. The caller can decide whether hidden
tags should be rendered or not, e.g., automatically set by the plugin
when shadow tags are disabled.
Arguments:
mapping: The mapping.
hidden: Whether to add hidden tags.

View File

@@ -44,6 +44,14 @@ class ListingConfig(Config):
subsection of the documentation.
"""
shadow = Optional(Type(bool))
"""
Whether to include shadow tags.
This setting allows to override the global setting for shadow tags. If this
setting is not specified, the global `shadow` setting is used.
"""
layout = Optional(Type(str))
"""
The layout to use for rendering the listing.
@@ -52,6 +60,14 @@ class ListingConfig(Config):
setting is not specified, the global `listings_layout` setting is used.
"""
toc = Optional(Type(bool))
"""
Whether to populate the table of contents with anchor links to tags.
This setting allows to override the global setting for the layout. If this
setting is not specified, the global `listings_toc` setting is used.
"""
include = TagSet()
"""
Tags to include in the listing.

View File

@@ -243,9 +243,9 @@ class ListingManager:
page = listing.page
assert isinstance(page.content, str)
# Add mappings to listing
# Add mappings to listing, passing shadow tags configuration
for mapping in mappings:
listing.add(mapping)
listing.add(mapping, hidden = listing.config.shadow)
# Sort listings and tags - we can only do this after all mappings have
# been added to the listing, because the tags inside the mappings do
@@ -376,10 +376,18 @@ class ListingManager:
f"{e}"
)
# Inherit shadow tags configuration, unless explicitly set
if not isinstance(config.shadow, bool):
config.shadow = self.config.shadow
# Inherit layout configuration, unless explicitly set
if not isinstance(config.layout, str):
config.layout = self.config.listings_layout
# Inherit table of contents configuration, unless explicitly set
if not isinstance(config.toc, bool):
config.toc = self.config.listings_toc
# Return listing configuration
return config
@@ -389,17 +397,29 @@ class ListingManager:
"""
Slugify tag.
If the tag hierarchy setting is enabled, the tag is expanded into a
hierarchy of tags, all of which are then slugified and joined with the
configured separator. Otherwise, the tag is slugified directly. This is
necessary to keep the tag hierarchy in the slug.
Arguments:
tag: The tag.
Returns:
The slug.
"""
slugify = self.config.tags_slugify
tags = [tag.name]
# Compute tag hierarchy, if configured
hierarchy = self.config.tags_hierarchy_separator
if self.config.tags_hierarchy:
tags = tag.name.split(hierarchy)
# Slugify tag hierarchy and join with separator
separator = self.config.tags_slugify_separator
return self.config.tags_slugify_format.format(
slug = self.config.tags_slugify(
tag.name,
self.config.tags_slugify_separator
)
slug = hierarchy.join(slugify(name, separator) for name in tags)
)
# -------------------------------------------------------------------------

View File

@@ -81,7 +81,10 @@ def populate(listing: Listing, slugify: Slugify) -> dict[Tag, AnchorLink]:
# Filter top-level anchor links and insert them into the page
children = [anchors[tag] for tag in anchors if not tag.parent]
host.children[at:at + 1] = children
if listing.config.toc:
host.children[at:at + 1] = children
else:
host.children.pop(at)
# Return mapping of tags to anchor links
return anchors

View File

@@ -34,6 +34,11 @@ class ListingTree:
"""
A listing tree.
Listing trees are a tree structure that represent the hierarchy of tags
and mappings. Each tree node is a tag, and each tag can have multiple
mappings. Additionally, each tree can have subtrees, which are typically
called nested tags.
This is an internal data structure that is used to render listings. It is
also the immediate structure that is passed to the template.
"""

View File

@@ -23,6 +23,7 @@ from __future__ import annotations
from collections.abc import Iterator
from material.plugins.tags.config import TagsConfig
from material.plugins.tags.structure.mapping import Mapping
from material.plugins.tags.structure.tag import Tag
from material.plugins.tags.structure.tag.options import TagSet
from mkdocs.structure.pages import Page
@@ -125,7 +126,7 @@ class MappingManager:
# Retrieve and validate tags, and add to mapping
for tag in self.format.validate(page.meta[tags]):
mapping.tags.add(tag)
mapping.tags.add(self._configure(tag))
# Return mapping
return mapping
@@ -143,6 +144,90 @@ class MappingManager:
if page.url in self.data:
return self.data[page.url]
# -------------------------------------------------------------------------
def _configure(self, tag: Tag) -> Tag:
"""
Configure tag.
This method is called by the mapping manager to configure a tag for the
the tag structure. Depending on the configuration, the tag is expanded
into a hierarchy of tags, and can be marked as hidden if it is a shadow
tag, hiding it from mappings and listings when rendering.
Arguments:
tag: The tag.
Returns:
The configured tag.
"""
if self.config.tags_hierarchy:
return self._configure_hierarchy(tag)
else:
return self._configure_shadow(tag, tag.name)
def _configure_hierarchy(self, tag: Tag) -> Tag:
"""
Configure hierarchical tag.
Note that shadow tags that occur as part of a tag hierarchy propagate
their hidden state to all of their children.
Arguments:
tag: The tag.
Returns:
The configured tag.
"""
separator = self.config.tags_hierarchy_separator
root, *rest = tag.name.split(separator)
# Create tag root and hierarchy
tag = self._configure_shadow(Tag(root), root)
for name in rest:
tag = self._configure_shadow(Tag(
separator.join([tag.name, name]),
parent = tag, hidden = tag.hidden
), name)
# Return tag
return tag
def _configure_shadow(self, tag: Tag, name: str) -> Tag:
"""
Configure shadow tag.
Regardless of the configuration, tags are always marked as hidden if
they're classified as shadow tags, e.g., if their name matches the
configured shadow prefix or suffix, or if they're part of the list of
shadow tags. Whether they're displayed is decided before rendering.
The tag name must be passed separately, as it may be different from the
tag's name, e.g., when creating a tag hierarchy. In this case, the name
represents the part that was added to the tag, essentially the suffix.
The name is checked for shadow prefixes and suffixes.
Arguments:
tag: The tag.
name: The tag name.
Returns:
The configured tag.
"""
if not tag.hidden:
tag.hidden = tag in self.config.shadow_tags
# Check if tag matches shadow prefix, if defined
if not tag.hidden and self.config.shadow_tags_prefix:
tag.hidden = name.startswith(self.config.shadow_tags_prefix)
# Check if tag matches shadow suffix, if defined
if not tag.hidden and self.config.shadow_tags_suffix:
tag.hidden = name.endswith(self.config.shadow_tags_suffix)
# Return tag
return tag
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------

View File

@@ -31,6 +31,35 @@ from functools import total_ordering
class Tag:
"""
A tag.
Tags can be used to categorize pages and group them into a tag structure. A
tag is a simple string, which can be split into a hierarchy of tags by using
the character or string as defined in the `hierarchy_separator` setting in
`mkdocs.yml`. Each parent tag contains their child tags.
Example:
```yaml
tags:
- foo/bar
- foo/baz
- qux
```
The tag structure for the above example would look like this:
```
.
├─ foo
│ ├─ bar
│ └─ baz
└─ qux
```
Note that this class does not split the tag name into a hierarchy of tags
by itself, but rather provides a simple interface to iterate over the tag
and its parents. Splitting is left to the caller, in order to allow for
changing the separator in `mkdocs.yml`.
"""
def __init__(

View File

@@ -1,150 +0,0 @@
/*
* 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.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.progress" /* Instant navigation progress */
| "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 */
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

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

View File

@@ -1,122 +0,0 @@
/*
* 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

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

View File

@@ -1,64 +0,0 @@
/*
* 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

@@ -1,28 +0,0 @@
/*
* 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

@@ -1,125 +0,0 @@
/*
* 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

@@ -1,77 +0,0 @@
/*
* 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

@@ -1,159 +0,0 @@
/*
* 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

@@ -1,104 +0,0 @@
/*
* 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

@@ -1,131 +0,0 @@
/*
* 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

@@ -1,32 +0,0 @@
/*
* 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

@@ -1,148 +0,0 @@
/*
* 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

@@ -1,85 +0,0 @@
/*
* 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 trigger be
* intercepted by the instant loading business logic.
*
* 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

@@ -1,104 +0,0 @@
/*
* 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

@@ -1,24 +0,0 @@
/*
* 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

@@ -1,95 +0,0 @@
/*
* 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

@@ -1,179 +0,0 @@
/*
* 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

@@ -1,70 +0,0 @@
/*
* 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

@@ -1,102 +0,0 @@
/*
* 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

@@ -1,69 +0,0 @@
/*
* 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

@@ -1,26 +0,0 @@
/*
* 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

@@ -1,78 +0,0 @@
/*
* 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

@@ -1,71 +0,0 @@
/*
* 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

@@ -1,112 +0,0 @@
/*
* 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

@@ -1,318 +0,0 @@
/*
* 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,
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 progress indicator */
const progress$ = new Subject<number>()
/* Set up instant navigation, if enabled */
if (feature("navigation.instant"))
setupInstantNavigation({ 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, { 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,29 +30,15 @@ import { getElement, getElements } from "~/browser"
* Component type
*/
export type ComponentType =
| "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 */
| "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 */
/**
* Component
@@ -76,29 +62,15 @@ export type Component<
* Component type map
*/
interface ComponentTypeMap {
"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 */
"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 */
}
/* ----------------------------------------------------------------------------
@@ -118,7 +90,7 @@ interface ComponentTypeMap {
export function getComponentElement<T extends ComponentType>(
type: T, node: ParentNode = document
): ComponentTypeMap[T] {
return getElement(`[data-md-component=${type}]`, node)
return getElement(`[data-mdx-component=${type}]`, node)
}
/**
@@ -134,5 +106,5 @@ export function getComponentElement<T extends ComponentType>(
export function getComponentElements<T extends ComponentType>(
type: T, node: ParentNode = document
): ComponentTypeMap[T][] {
return getElements(`[data-md-component=${type}]`, node)
return getElements(`[data-mdx-component=${type}]`, node)
}

View File

@@ -1,110 +0,0 @@
/*
* 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

@@ -1,116 +0,0 @@
/*
* 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

@@ -1,136 +0,0 @@
/*
* 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 { Component } from "../../_"
import {
Tooltip,
mountInlineTooltip2
} from "../../tooltip2"
import {
Annotation,
mountAnnotationBlock
} from "../annotation"
import {
CodeBlock,
mountCodeBlock
} from "../code"
import {
Details,
mountDetails
} from "../details"
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
| Mermaid
| Tooltip
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
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 options - Options
*
* @returns Content component observable
*/
export function mountContent(
el: HTMLElement, { viewport$, target$, print$ }: MountOptions
): Observable<Component<Content>> {
return merge(
/* Annotations */
...getElements(".annotate:not(.highlight)", el)
.map(child => mountAnnotationBlock(child, { target$, print$ })),
/* Code blocks */
...getElements("pre:not(.mermaid) > code", el)
.map(child => mountCodeBlock(child, { target$, print$ })),
/* 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]", el)
.filter(() => feature("content.tooltips"))
.map(child => mountInlineTooltip2(child, { viewport$ }))
)
}

View File

@@ -1,272 +0,0 @@
/*
* 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

@@ -1,88 +0,0 @@
/*
* 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

@@ -1,25 +0,0 @@
/*
* 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

@@ -1,209 +0,0 @@
/*
* 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 {
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[] {
return container.tagName === "CODE"
? getElements(".c, .c1, .cm", container)
: [container]
}
/**
* Find all annotation markers in the containing element
*
* @param container - Containing element
*
* @returns Annotation markers
*/
function 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

@@ -1,259 +0,0 @@
/*
* 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,
defer,
distinctUntilChanged,
distinctUntilKeyChanged,
filter,
finalize,
map,
mergeWith,
switchMap,
take,
takeLast,
takeUntil,
tap
} from "rxjs"
import { feature } from "~/_"
import {
getElementContentSize,
getElements,
watchElementSize,
watchElementVisibility
} from "~/browser"
import {
Tooltip,
mountInlineTooltip2
} from "~/components/tooltip2"
import { renderClipboardButton } 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
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Find candidate list element directly following a code block
*
* @param el - Code block element
*
* @returns List element or nothing
*/
function findCandidateList(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 findCandidateList(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")
})
/* Render button for Clipboard.js integration */
const content$: Array<Observable<Component<CodeBlock>>> = []
if (ClipboardJS.isSupported()) {
if (el.closest(".copy") || (
feature("content.code.copy") && !el.closest(".no-copy")
)) {
const parent = el.closest("pre")!
parent.id = `__code_${sequence++}`
/* Mount tooltip, if enabled */
const button = renderClipboardButton(parent.id)
parent.insertBefore(button, el)
if (feature("content.tooltips"))
content$.push(mountInlineTooltip2(button, { viewport$ }))
}
}
/* Handle code annotations */
const container = el.closest(".highlight")
if (container instanceof HTMLElement) {
const list = findCandidateList(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)
)
)
}
}
// If the code block has line spans, we can add this additional class to
// the code block element, which fixes the problem for highlighted code
// lines not stretching to the entirety of the screen when the code block
// overflows, e.g., on mobile - see
const spans = getElements(":scope > span[id]", el)
if (spans.length)
el.classList.add("md-code__content")
/* Create and return component */
return watchCodeBlock(el)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state })),
mergeWith(...content$)
)
})
/* 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

@@ -1,23 +0,0 @@
/*
* 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

@@ -1,138 +0,0 @@
/*
* 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

@@ -1,29 +0,0 @@
/*
* 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 "./mermaid"
export * from "./table"
export * from "./tabs"

View File

@@ -1,415 +0,0 @@
/*
* 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

@@ -1,132 +0,0 @@
/*
* 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

@@ -1,305 +0,0 @@
/*
* 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

@@ -1,128 +0,0 @@
/*
* 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

@@ -1,216 +0,0 @@
/*
* 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
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
viewport$: Observable<Viewport> /* Viewport observable */
}
/**
* Mount options
*/
interface MountOptions {
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 options - Options
*
* @returns Toggle observable
*/
function isHidden({ viewport$ }: WatchOptions): 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 options - Options
*
* @returns Header observable
*/
export function watchHeader(
el: HTMLElement, options: WatchOptions
): Observable<Header> {
return defer(() => combineLatest([
watchElementSize(el),
isHidden(options)
]))
.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 options - Options
*
* @returns Header component observable
*/
export function mountHeader(
el: HTMLElement, { header$, main$ }: MountOptions
): 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$)))
)
})
}

View File

@@ -1,24 +0,0 @@
/*
* 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 "./title"

View File

@@ -1,144 +0,0 @@
/*
* 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,
distinctUntilKeyChanged,
finalize,
map,
tap
} from "rxjs"
import {
Viewport,
getElementSize,
getOptionalElement,
watchViewportAt
} from "~/browser"
import { Component } from "../../_"
import { Header } from "../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Header
*/
export interface HeaderTitle {
active: boolean /* Header title is active */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
}
/**
* Mount options
*/
interface MountOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch header title
*
* @param el - Heading element
* @param options - Options
*
* @returns Header title observable
*/
export function watchHeaderTitle(
el: HTMLElement, { viewport$, header$ }: WatchOptions
): Observable<HeaderTitle> {
return watchViewportAt(el, { viewport$, header$ })
.pipe(
map(({ offset: { y } }) => {
const { height } = getElementSize(el)
return {
active: height > 0 && y >= height
}
}),
distinctUntilKeyChanged("active")
)
}
/**
* Mount header title
*
* This function swaps the header title from the site title to the title of the
* current page when the user scrolls past the first headline.
*
* @param el - Header title element
* @param options - Options
*
* @returns Header title component observable
*/
export function mountHeaderTitle(
el: HTMLElement, options: MountOptions
): Observable<Component<HeaderTitle>> {
return defer(() => {
const push$ = new Subject<HeaderTitle>()
push$.subscribe({
/* Handle emission */
next({ active }) {
el.classList.toggle("md-header__title--active", active)
},
/* Handle complete */
complete() {
el.classList.remove("md-header__title--active")
}
})
/* Obtain headline, if any */
const heading = getOptionalElement(".md-content h1")
if (typeof heading === "undefined")
return EMPTY
/* Create and return component */
return watchHeaderTitle(heading, options)
.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 { BehaviorSubject, Observable, fromEvent, map, merge } from "rxjs"
import { configuration } from "~/_"
import { requestJSON } from "~/browser"
import {
Component,
getComponentElement,
getComponentElements
} from "../../_"
import {
IconSearchQuery,
mountIconSearchQuery
} from "../query"
import {
IconSearchResult,
mountIconSearchResult
} from "../result"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Icon category
*/
export interface IconCategory {
base: string /* Category base URL */
data: Record<string, string> /* Category data */
}
/**
* Icon search index
*/
export interface IconSearchIndex {
icons: IconCategory /* Icons */
emojis: IconCategory /* Emojis */
}
/* ------------------------------------------------------------------------- */
/**
* Icon search
*/
export type IconSearch =
| IconSearchQuery
| IconSearchResult
/**
* Icon search mode
*/
export type IconSearchMode =
| "all"
| "icons"
| "emojis"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount icon search
*
* @param el - Icon search element
*
* @returns Icon search component observable
*/
export function mountIconSearch(
el: HTMLElement
): Observable<Component<IconSearch>> {
const config = configuration()
const index$ = requestJSON<IconSearchIndex>(
new URL("assets/javascripts/iconsearch_index.json", config.base)
)
/* Retrieve query and result components */
const query = getComponentElement("iconsearch-query", el)
const result = getComponentElement("iconsearch-result", el)
/* Retrieve select component */
const mode$ = new BehaviorSubject<IconSearchMode>("all")
const selects = getComponentElements("iconsearch-select", el)
for (const select of selects) {
fromEvent(select, "change").pipe(
map(ev => (ev.target as HTMLSelectElement).value as IconSearchMode)
)
.subscribe(mode$)
}
/* Create and return component */
const query$ = mountIconSearchQuery(query)
const result$ = mountIconSearchResult(result, { index$, query$, mode$ })
return merge(query$, result$)
}

View File

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

View File

@@ -23,25 +23,30 @@
import {
Observable,
combineLatest,
distinctUntilKeyChanged,
map
delay,
distinctUntilChanged,
filter,
fromEvent,
map,
merge,
startWith,
withLatestFrom
} from "rxjs"
import { Header } from "~/components"
import { watchElementFocus } from "~/browser"
import { getElementOffset } from "../../element"
import { Viewport } from "../_"
import { Component } from "../../_"
/* ----------------------------------------------------------------------------
* Helper types
* Types
* ------------------------------------------------------------------------- */
/**
* Watch options
* Icon search query
*/
interface WatchOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
export interface IconSearchQuery {
value: string /* Query value */
focus: boolean /* Query focus */
}
/* ----------------------------------------------------------------------------
@@ -49,36 +54,43 @@ interface WatchOptions {
* ------------------------------------------------------------------------- */
/**
* Watch viewport relative to element
* Mount icon search query
*
* @param el - Element
* @param options - Options
* @param el - Icon search query element
*
* @returns Viewport observable
* @returns Icon search query component observable
*/
export function watchViewportAt(
el: HTMLElement, { viewport$, header$ }: WatchOptions
): Observable<Viewport> {
const size$ = viewport$
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))
)
.pipe(
distinctUntilKeyChanged("size")
map(() => el.value),
startWith(el.value),
distinctUntilChanged(),
)
/* Compute element offset */
const offset$ = combineLatest([size$, header$])
/* Log search on blur */
focus$
.pipe(
map(() => getElementOffset(el))
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}`)
})
/* Compute relative viewport, return hot observable */
return combineLatest([header$, viewport$, offset$])
/* Combine into single observable */
return combineLatest([value$, focus$])
.pipe(
map(([{ height }, { offset, size }, { x, y }]) => ({
offset: {
x: offset.x - x,
y: offset.y - y + height
},
size
}))
map(([value, focus]) => ({ ref: el, value, focus })),
)
}

View File

@@ -0,0 +1,245 @@
/*
* 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 { filter as search } from "fuzzaldrin-plus"
import {
Observable,
Subject,
bufferCount,
combineLatest,
combineLatestWith,
distinctUntilKeyChanged,
filter,
finalize,
map,
merge,
of,
switchMap,
tap,
withLatestFrom,
zipWith
} from "rxjs"
import {
getElement,
watchElementBoundary
} from "~/browser"
import { round } from "~/utilities"
import { Icon, renderIconSearchResult } from "_/templates"
import { Component } from "../../_"
import { IconSearchIndex, IconSearchMode } from "../_"
import { IconSearchQuery } from "../query"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Icon search result
*/
export interface IconSearchResult {
data: Icon[] /* Search result data */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
index$: Observable<IconSearchIndex> /* Search index observable */
query$: Observable<IconSearchQuery> /* Search query observable */
mode$: Observable<IconSearchMode> /* Search mode observable */
}
/**
* Mount options
*/
interface MountOptions {
index$: Observable<IconSearchIndex> /* Search index observable */
query$: Observable<IconSearchQuery> /* Search query observable */
mode$: Observable<IconSearchMode> /* Search mode observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch icon search result
*
* @param el - Icon search result element
* @param options - Options
*
* @returns Icon search result observable
*/
export function watchIconSearchResult(
el: HTMLElement, { index$, query$, mode$ }: WatchOptions
): Observable<IconSearchResult> {
switch (el.getAttribute("data-mdx-mode")) {
case "file":
return combineLatest([
query$.pipe(distinctUntilKeyChanged("value")),
index$
.pipe(
map(({ icons }) => Object.values(icons.data)
.map(icon => icon.replace(/\.svg$/, ""))
)
)
])
.pipe(
map(([{ value }, data]) => search(data, value)),
switchMap(files => index$.pipe(
map(({ icons }) => ({
data: files.map<Icon>(shortcode => {
return {
shortcode,
url: [
icons.base,
shortcode,
".svg"
].join("")
}
})
}))
))
)
default:
return combineLatest([
query$.pipe(distinctUntilKeyChanged("value")),
index$
.pipe(
combineLatestWith(mode$),
map(([{ icons, emojis }, mode]) => [
...["all", "icons"].includes(mode)
? Object.keys(icons.data)
: [],
...["all", "emojis"].includes(mode)
? Object.keys(emojis.data)
: []
])
)
])
.pipe(
map(([{ value }, data]) => search(data, value)),
switchMap(shortcodes => index$.pipe(
map(({ icons, emojis }) => ({
data: shortcodes.map<Icon>(shortcode => {
const category =
shortcode in icons.data
? icons
: emojis
return {
shortcode,
url: [
category.base,
category.data[shortcode]
].join("")
}
})
}))
))
)
}
}
/**
* Mount icon search result
*
* @param el - Icon search result element
* @param options - Options
*
* @returns Icon search result component observable
*/
export function mountIconSearchResult(
el: HTMLElement, { index$, query$, mode$ }: MountOptions
): Observable<Component<IconSearchResult, HTMLElement>> {
const push$ = new Subject<IconSearchResult>()
const boundary$ = watchElementBoundary(el)
.pipe(
filter(Boolean)
)
/* Update search result metadata */
const meta = getElement(".mdx-iconsearch-result__meta", el)
push$
.pipe(
withLatestFrom(query$)
)
.subscribe(([{ data }, { value }]) => {
if (value) {
switch (data.length) {
/* No results */
case 0:
meta.textContent = "No matches"
break
/* One result */
case 1:
meta.textContent = "1 match"
break
/* Multiple result */
default:
meta.textContent = `${round(data.length)} matches`
}
} else {
meta.textContent = "Type to start searching"
}
})
/* Update icon search result list */
const file = el.getAttribute("data-mdx-mode") === "file"
const list = getElement(":scope > :last-child", el)
push$
.pipe(
tap(() => list.innerHTML = ""),
switchMap(({ data }) => merge(
of(...data.slice(0, 10)),
of(...data.slice(10))
.pipe(
bufferCount(10),
zipWith(boundary$),
switchMap(([chunk]) => chunk)
)
)),
withLatestFrom(query$)
)
.subscribe(([result, { value }]) => list.appendChild(
renderIconSearchResult(result, value, file)
))
/* Create and return component */
return watchIconSearchResult(el, { query$, index$, mode$ })
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@@ -21,18 +21,6 @@
*/
export * from "./_"
export * from "./announce"
export * from "./consent"
export * from "./content"
export * from "./dialog"
export * from "./header"
export * from "./main"
export * from "./palette"
export * from "./progress"
export * from "./search"
export * from "./sidebar"
export * from "./source"
export * from "./tabs"
export * from "./toc"
export * from "./tooltip"
export * from "./top"
export * from "./iconsearch"
export * from "./parallax"
export * from "./sponsorship"

View File

@@ -1,125 +0,0 @@
/*
* 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,
distinctUntilChanged,
distinctUntilKeyChanged,
map,
switchMap
} from "rxjs"
import {
Viewport,
watchElementSize
} from "~/browser"
import { Header } from "../header"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Main area
*/
export interface Main {
offset: number /* Main area top offset */
height: number /* Main area visible height */
active: boolean /* Main area is active */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch main area
*
* This function returns an observable that computes the visual parameters of
* the main area which depends on the viewport vertical offset and height, as
* well as the height of the header element, if the header is fixed.
*
* @param el - Main area element
* @param options - Options
*
* @returns Main area observable
*/
export function watchMain(
el: HTMLElement, { viewport$, header$ }: WatchOptions
): Observable<Main> {
/* Compute necessary adjustment for header */
const adjust$ = header$
.pipe(
map(({ height }) => height),
distinctUntilChanged()
)
/* Compute the main area's top and bottom borders */
const border$ = adjust$
.pipe(
switchMap(() => watchElementSize(el)
.pipe(
map(({ height }) => ({
top: el.offsetTop,
bottom: el.offsetTop + height
})),
distinctUntilKeyChanged("bottom")
)
)
)
/* Compute the main area's offset, visible height and if we scrolled past */
return combineLatest([adjust$, border$, viewport$])
.pipe(
map(([header, { top, bottom }, { offset: { y }, size: { height } }]) => {
height = Math.max(0, height
- Math.max(0, top - y, header)
- Math.max(0, height + y - bottom)
)
return {
offset: top - header,
height,
active: top - header <= y
}
}),
distinctUntilChanged((a, b) => (
a.offset === b.offset &&
a.height === b.height &&
a.active === b.active
))
)
}

View File

@@ -1,212 +0,0 @@
/*
* 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,
asyncScheduler,
defer,
filter,
finalize,
fromEvent,
map,
mergeMap,
observeOn,
of,
repeat,
shareReplay,
skip,
startWith,
takeUntil,
tap,
withLatestFrom
} from "rxjs"
import { getElements, watchMedia } from "~/browser"
import { h } from "~/utilities"
import {
Component,
getComponentElement
} from "../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Palette colors
*/
export interface PaletteColor {
media?: string /* Media query */
scheme?: string /* Color scheme */
primary?: string /* Primary color */
accent?: string /* Accent color */
}
/**
* Palette
*/
export interface Palette {
index: number /* Palette index */
color: PaletteColor /* Palette colors */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch color palette
*
* @param inputs - Color palette element
*
* @returns Color palette observable
*/
export function watchPalette(
inputs: HTMLInputElement[]
): Observable<Palette> {
const current = __md_get<Palette>("__palette") || {
index: inputs.findIndex(input => matchMedia(
input.getAttribute("data-md-color-media")!
).matches)
}
/* Emit changes in color palette */
const index = Math.max(0, Math.min(current.index, inputs.length - 1))
return of(...inputs)
.pipe(
mergeMap(input => fromEvent(input, "change").pipe(map(() => input))),
startWith(inputs[index]),
map(input => ({
index: inputs.indexOf(input),
color: {
media: input.getAttribute("data-md-color-media"),
scheme: input.getAttribute("data-md-color-scheme"),
primary: input.getAttribute("data-md-color-primary"),
accent: input.getAttribute("data-md-color-accent")
}
} as Palette)),
shareReplay(1)
)
}
/**
* Mount color palette
*
* @param el - Color palette element
*
* @returns Color palette component observable
*/
export function mountPalette(
el: HTMLElement
): Observable<Component<Palette>> {
const inputs = getElements<HTMLInputElement>("input", el)
const meta = h("meta", { name: "theme-color" })
document.head.appendChild(meta)
// Add color scheme meta tag
const scheme = h("meta", { name: "color-scheme" })
document.head.appendChild(scheme)
/* Mount component on subscription */
const media$ = watchMedia("(prefers-color-scheme: light)")
return defer(() => {
const push$ = new Subject<Palette>()
push$.subscribe(palette => {
document.body.setAttribute("data-md-color-switching", "")
/* Retrieve color palette for system preference */
if (palette.color.media === "(prefers-color-scheme)") {
const media = matchMedia("(prefers-color-scheme: light)")
const input = document.querySelector(media.matches
? "[data-md-color-media='(prefers-color-scheme: light)']"
: "[data-md-color-media='(prefers-color-scheme: dark)']"
)!
/* Retrieve colors for system preference */
palette.color.scheme = input.getAttribute("data-md-color-scheme")!
palette.color.primary = input.getAttribute("data-md-color-primary")!
palette.color.accent = input.getAttribute("data-md-color-accent")!
}
/* Set color palette */
for (const [key, value] of Object.entries(palette.color))
document.body.setAttribute(`data-md-color-${key}`, value)
/* Set toggle visibility */
for (let index = 0; index < inputs.length; index++) {
const label = inputs[index].nextElementSibling
if (label instanceof HTMLElement)
label.hidden = palette.index !== index
}
/* Persist preference in local storage */
__md_set("__palette", palette)
})
// Handle color switch on Enter or Space - see https://t.ly/YIhVj
fromEvent<KeyboardEvent>(el, "keydown").pipe(
filter(ev => ev.key === "Enter"),
withLatestFrom(push$, (_, palette) => palette)
)
.subscribe(({ index }) => {
index = (index + 1) % inputs.length
inputs[index].click()
inputs[index].focus()
})
/* Update theme-color meta tag */
push$
.pipe(
map(() => {
const header = getComponentElement("header")
const style = window.getComputedStyle(header)
// Set color scheme
scheme.content = style.colorScheme
/* Return color in hexadecimal format */
return style.backgroundColor.match(/\d+/g)!
.map(value => (+value).toString(16).padStart(2, "0"))
.join("")
})
)
.subscribe(color => meta.content = `#${color}`)
/* Revert transition durations after color switch */
push$.pipe(observeOn(asyncScheduler))
.subscribe(() => {
document.body.removeAttribute("data-md-color-switching")
})
/* Create and return component */
return watchPalette(inputs)
.pipe(
takeUntil(media$.pipe(skip(1))),
repeat(),
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -0,0 +1,145 @@
/*
* 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,
combineLatest,
defer,
distinctUntilChanged,
filter,
finalize,
map,
merge,
take,
tap
} from "rxjs"
import {
getElement,
getElements,
watchElementContentOffset,
watchElementOffset,
watchElementVisibility
} from "~/browser"
import {
Component,
getComponentElement
} from "../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Parallax
*/
export interface Parallax {
active: HTMLElement /* Active parallax element */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch parallax
*
* @param el - Parallax element
*
* @returns Parallax observable
*/
export function watchParallax(
el: HTMLElement
): Observable<Parallax> {
return merge(
...getElements(":scope [hidden]", el)
.map(child => watchElementVisibility(child)
.pipe(
filter(visible => visible),
take(1),
map(() => ({ active: child }))
)
)
)
}
/**
* Mount parallax
*
* @param el - Parallax element
*
* @returns Parallax component observable
*/
export function mountParallax(
el: HTMLElement
): Observable<Component<Parallax>> {
return defer(() => {
const push$ = new Subject<Parallax>()
push$.subscribe(({ active }) => {
active.hidden = false
})
/* Hack: immediately hide hero on Firefox due to rendering bugs. */
if (navigator.userAgent.includes("Gecko/"))
watchElementContentOffset(el)
.pipe(
map(({ y }) => y > 1),
distinctUntilChanged()
)
.subscribe(hidden => {
const hero = getComponentElement("hero")
hero.hidden = hidden
})
/* Another hack: reset containment to mitigate #8462 */
if (navigator.userAgent.includes("Gecko/"))
watchElementContentOffset(el)
.pipe(
map(({ y }) => y > 3000),
distinctUntilChanged()
)
.subscribe(active => {
document.body.classList.toggle("ff-hack", !active)
})
/* Reveal header when scrolling past second group */
const group = getElement(":scope > :nth-child(2)", el)
combineLatest([
watchElementContentOffset(el),
watchElementOffset(group)
])
.subscribe(([{ y }, offset]) => {
const header = getElement("header")
header.classList.toggle("md-header--shadow", y > offset.y)
})
/* Create and return component */
return watchParallax(el)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -1,87 +0,0 @@
/*
* 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,
finalize,
map,
tap
} from "rxjs"
import { Component } from "../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Progress indicator
*/
export interface Progress {
value: number // Progress value
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
progress$: Subject<number> // Progress subject
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount progress indicator
*
* @param el - Progress indicator element
* @param options - Options
*
* @returns Progress indicator component observable
*/
export function mountProgress(
el: HTMLElement, { progress$ }: MountOptions
): Observable<Component<Progress>> {
// Mount component on subscription
return defer(() => {
const push$ = new Subject<Progress>()
push$.subscribe(({ value }) => {
el.style.setProperty("--md-progress-value", `${value}`)
})
// Create and return component
return progress$
.pipe(
tap(value => push$.next({ value })),
finalize(() => push$.complete()),
map(value => ({ ref: el, value }))
)
})
}

View File

@@ -1,239 +0,0 @@
/*
* 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,
ObservableInput,
filter,
fromEvent,
merge,
mergeWith
} from "rxjs"
import { configuration } from "~/_"
import {
Keyboard,
getActiveElement,
getElements,
setToggle
} from "~/browser"
import {
SearchIndex,
SearchResult,
setupSearchWorker
} from "~/integrations"
import {
Component,
getComponentElement,
getComponentElements
} from "../../_"
import {
SearchQuery,
mountSearchQuery
} from "../query"
import { mountSearchResult } from "../result"
import {
SearchShare,
mountSearchShare
} from "../share"
import {
SearchSuggest,
mountSearchSuggest
} from "../suggest"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search
*/
export type Search =
| SearchQuery
| SearchResult
| SearchShare
| SearchSuggest
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
index$: ObservableInput<SearchIndex> /* Search index observable */
keyboard$: Observable<Keyboard> /* Keyboard observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount search
*
* This function sets up the search functionality, including the underlying
* web worker and all keyboard bindings.
*
* @param el - Search element
* @param options - Options
*
* @returns Search component observable
*/
export function mountSearch(
el: HTMLElement, { index$, keyboard$ }: MountOptions
): Observable<Component<Search>> {
const config = configuration()
try {
const worker$ = setupSearchWorker(config.search, index$)
/* Retrieve query and result components */
const query = getComponentElement("search-query", el)
const result = getComponentElement("search-result", el)
/* Always close search on result selection */
fromEvent<PointerEvent>(el, "click")
.pipe(
filter(({ target }) => (
target instanceof Element && !!target.closest("a")
))
)
.subscribe(() => setToggle("search", false))
/* Set up search keyboard handlers */
keyboard$
.pipe(
filter(({ mode }) => mode === "search")
)
.subscribe(key => {
const active = getActiveElement()
switch (key.type) {
/* Enter: go to first (best) result */
case "Enter":
if (active === query) {
const anchors = new Map<HTMLAnchorElement, number>()
for (const anchor of getElements<HTMLAnchorElement>(
":first-child [href]", result
)) {
const article = anchor.firstElementChild!
anchors.set(anchor, parseFloat(
article.getAttribute("data-md-score")!
))
}
/* Go to result with highest score, if any */
if (anchors.size) {
const [[best]] = [...anchors].sort(([, a], [, b]) => b - a)
best.click()
}
/* Otherwise omit form submission */
key.claim()
}
break
/* Escape or Tab: close search */
case "Escape":
case "Tab":
setToggle("search", false)
query.blur()
break
/* Vertical arrows: select previous or next search result */
case "ArrowUp":
case "ArrowDown":
if (typeof active === "undefined") {
query.focus()
} else {
const els = [query, ...getElements(
":not(details) > [href], summary, details[open] [href]",
result
)]
const i = Math.max(0, (
Math.max(0, els.indexOf(active)) + els.length + (
key.type === "ArrowUp" ? -1 : +1
)
) % els.length)
els[i].focus()
}
/* Prevent scrolling of page */
key.claim()
break
/* All other keys: hand to search query */
default:
if (query !== getActiveElement())
query.focus()
}
})
/* Set up global keyboard handlers */
keyboard$
.pipe(
filter(({ mode }) => mode === "global")
)
.subscribe(key => {
switch (key.type) {
/* Open search and select query */
case "f":
case "s":
case "/":
query.focus()
query.select()
/* Prevent scrolling of page */
key.claim()
break
}
})
/* Create and return component */
const query$ = mountSearchQuery(query, { worker$ })
return merge(
query$,
mountSearchResult(result, { worker$, query$ })
)
.pipe(
mergeWith(
/* Search sharing */
...getComponentElements("search-share", el)
.map(child => mountSearchShare(child, { query$ })),
/* Search suggestions */
...getComponentElements("search-suggest", el)
.map(child => mountSearchSuggest(child, { worker$, keyboard$ }))
)
)
/* Gracefully handle broken search */
} catch (err) {
el.hidden = true
return NEVER
}
}

View File

@@ -1,5 +0,0 @@
{
"rules": {
"no-null/no-null": "off"
}
}

View File

@@ -1,115 +0,0 @@
/*
* 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,
ObservableInput,
combineLatest,
filter,
map,
startWith
} from "rxjs"
import { getLocation } from "~/browser"
import {
SearchIndex,
setupSearchHighlighter
} from "~/integrations"
import { h } from "~/utilities"
import { Component } from "../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search highlighting
*/
export interface SearchHighlight {
nodes: Map<ChildNode, string> /* Map of replacements */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
index$: ObservableInput<SearchIndex> /* Search index observable */
location$: Observable<URL> /* Location observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount search highlighting
*
* @param el - Content element
* @param options - Options
*
* @returns Search highlighting component observable
*/
export function mountSearchHiglight(
el: HTMLElement, { index$, location$ }: MountOptions
): Observable<Component<SearchHighlight>> {
return combineLatest([
index$,
location$
.pipe(
startWith(getLocation()),
filter(url => !!url.searchParams.get("h"))
)
])
.pipe(
map(([index, url]) => setupSearchHighlighter(index.config)(
url.searchParams.get("h")!
)),
map(fn => {
const nodes = new Map<ChildNode, string>()
/* Traverse text nodes and collect matches */
const it = document.createNodeIterator(el, NodeFilter.SHOW_TEXT)
for (let node = it.nextNode(); node; node = it.nextNode()) {
if (node.parentElement?.offsetHeight) {
const original = node.textContent!
const replaced = fn(original)
if (replaced.length > original.length)
nodes.set(node as ChildNode, replaced)
}
}
/* Replace original nodes with matches */
for (const [node, text] of nodes) {
const { childNodes } = h("span", null, text)
node.replaceWith(...Array.from(childNodes))
}
/* Return component */
return { ref: el, nodes }
})
)
}

View File

@@ -1,28 +0,0 @@
/*
* 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 "./highlight"
export * from "./query"
export * from "./result"
export * from "./share"
export * from "./suggest"

View File

@@ -1,206 +0,0 @@
/*
* 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,
combineLatest,
distinctUntilChanged,
distinctUntilKeyChanged,
endWith,
finalize,
first,
fromEvent,
ignoreElements,
map,
merge,
shareReplay,
takeUntil,
tap
} from "rxjs"
import {
getElement,
getLocation,
setToggle,
watchElementFocus,
watchToggle
} from "~/browser"
import {
SearchMessage,
SearchMessageType,
isSearchReadyMessage
} from "~/integrations"
import { Component } from "../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search query
*/
export interface SearchQuery {
value: string /* Query value */
focus: boolean /* Query focus */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
worker$: Subject<SearchMessage> /* Search worker */
}
/**
* Mount options
*/
interface MountOptions {
worker$: Subject<SearchMessage> /* Search worker */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch search query
*
* Note that the focus event which triggers re-reading the current query value
* is delayed by `1ms` so the input's empty state is allowed to propagate.
*
* @param el - Search query element
* @param options - Options
*
* @returns Search query observable
*/
export function watchSearchQuery(
el: HTMLInputElement, { worker$ }: WatchOptions
): Observable<SearchQuery> {
/* Support search deep linking */
const { searchParams } = getLocation()
if (searchParams.has("q")) {
setToggle("search", true)
/* Set query from parameter */
el.value = searchParams.get("q")!
el.focus()
/* Remove query parameter on close */
watchToggle("search")
.pipe(
first(active => !active)
)
.subscribe(() => {
const url = getLocation()
url.searchParams.delete("q")
history.replaceState({}, "", `${url}`)
})
}
/* Intercept focus and input events */
const focus$ = watchElementFocus(el)
const value$ = merge(
worker$.pipe(first(isSearchReadyMessage)),
fromEvent(el, "keyup"),
focus$
)
.pipe(
map(() => el.value),
distinctUntilChanged()
)
/* Combine into single observable */
return combineLatest([value$, focus$])
.pipe(
map(([value, focus]) => ({ value, focus })),
shareReplay(1)
)
}
/**
* Mount search query
*
* @param el - Search query element
* @param options - Options
*
* @returns Search query component observable
*/
export function mountSearchQuery(
el: HTMLInputElement, { worker$ }: MountOptions
): Observable<Component<SearchQuery, HTMLInputElement>> {
const push$ = new Subject<SearchQuery>()
const done$ = push$.pipe(ignoreElements(), endWith(true))
/* Handle value change */
combineLatest([
worker$.pipe(first(isSearchReadyMessage)),
push$
], (_, query) => query)
.pipe(
distinctUntilKeyChanged("value")
)
.subscribe(({ value }) => worker$.next({
type: SearchMessageType.QUERY,
data: value
}))
/* Handle focus change */
push$
.pipe(
distinctUntilKeyChanged("focus")
)
.subscribe(({ focus }) => {
if (focus)
setToggle("search", focus)
})
/* Handle reset */
fromEvent(el.form!, "reset")
.pipe(
takeUntil(done$)
)
.subscribe(() => el.focus())
// Focus search query on label click - note that this is necessary to bring
// up the keyboard on iOS and other mobile platforms, as the search dialog is
// not visible at first, and programatically focusing an input element must
// be triggered by a user interaction - see https://t.ly/Cb30n
const label = getElement("header [for=__search]")
fromEvent(label, "click")
.subscribe(() => el.focus())
/* Create and return component */
return watchSearchQuery(el, { worker$ })
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state })),
shareReplay(1)
)
}

View File

@@ -1,198 +0,0 @@
/*
* 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,
bufferCount,
filter,
finalize,
first,
fromEvent,
map,
merge,
mergeMap,
of,
share,
skipUntil,
switchMap,
takeUntil,
tap,
withLatestFrom,
zipWith
} from "rxjs"
import { translation } from "~/_"
import {
getElement,
getOptionalElement,
watchElementBoundary,
watchToggle
} from "~/browser"
import {
SearchMessage,
SearchResult,
isSearchReadyMessage,
isSearchResultMessage
} from "~/integrations"
import { renderSearchResultItem } from "~/templates"
import { round } from "~/utilities"
import { Component } from "../../_"
import { SearchQuery } from "../query"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
query$: Observable<SearchQuery> /* Search query observable */
worker$: Subject<SearchMessage> /* Search worker */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount search result list
*
* This function performs a lazy rendering of the search results, depending on
* the vertical offset of the search result container.
*
* @param el - Search result list element
* @param options - Options
*
* @returns Search result list component observable
*/
export function mountSearchResult(
el: HTMLElement, { worker$, query$ }: MountOptions
): Observable<Component<SearchResult>> {
const push$ = new Subject<SearchResult>()
const boundary$ = watchElementBoundary(el.parentElement!)
.pipe(
filter(Boolean)
)
/* Retrieve container */
const container = el.parentElement!
/* Retrieve nested components */
const meta = getElement(":scope > :first-child", el)
const list = getElement(":scope > :last-child", el)
/* Reveal to accessibility tree see https://bit.ly/3iAA7t8 */
watchToggle("search")
.subscribe(active => {
list.setAttribute("role", active ? "list" : "presentation")
list.hidden = !active
})
/* Update search result metadata */
push$
.pipe(
withLatestFrom(query$),
skipUntil(worker$.pipe(first(isSearchReadyMessage)))
)
.subscribe(([{ items }, { value }]) => {
switch (items.length) {
/* No results */
case 0:
meta.textContent = value.length
? translation("search.result.none")
: translation("search.result.placeholder")
break
/* One result */
case 1:
meta.textContent = translation("search.result.one")
break
/* Multiple result */
default:
const count = round(items.length)
meta.textContent = translation("search.result.other", count)
}
})
/* Render search result item */
const render$ = push$
.pipe(
tap(() => list.innerHTML = ""),
switchMap(({ items }) => merge(
of(...items.slice(0, 10)),
of(...items.slice(10))
.pipe(
bufferCount(4),
zipWith(boundary$),
switchMap(([chunk]) => chunk)
)
)),
map(renderSearchResultItem),
share()
)
/* Update search result list */
render$.subscribe(item => list.appendChild(item))
render$
.pipe(
mergeMap(item => {
const details = getOptionalElement("details", item)
if (typeof details === "undefined")
return EMPTY
/* Keep position of details element stable */
return fromEvent(details, "toggle")
.pipe(
takeUntil(push$),
map(() => details)
)
})
)
.subscribe(details => {
if (
details.open === false &&
details.offsetTop <= container.scrollTop
)
container.scrollTo({ top: details.offsetTop })
})
/* Filter search result message */
const result$ = worker$
.pipe(
filter(isSearchResultMessage),
map(({ data }) => data)
)
/* Create and return component */
return result$
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@@ -1,135 +0,0 @@
/*
* 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,
finalize,
fromEvent,
ignoreElements,
map,
takeUntil,
tap
} from "rxjs"
import { getLocation } from "~/browser"
import { Component } from "../../_"
import { SearchQuery } from "../query"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search sharing
*/
export interface SearchShare {
url: URL /* Deep link for sharing */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
query$: Observable<SearchQuery> /* Search query observable */
}
/**
* Mount options
*/
interface MountOptions {
query$: Observable<SearchQuery> /* Search query observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount search sharing
*
* @param _el - Search sharing element
* @param options - Options
*
* @returns Search sharing observable
*/
export function watchSearchShare(
_el: HTMLElement, { query$ }: WatchOptions
): Observable<SearchShare> {
return query$
.pipe(
map(({ value }) => {
const url = getLocation()
url.hash = ""
/* Compute readable query strings */
value = value
.replace(/\s+/g, "+") /* Collapse whitespace */
.replace(/&/g, "%26") /* Escape '&' character */
.replace(/=/g, "%3D") /* Escape '=' character */
/* Replace query string */
url.search = `q=${value}`
return { url }
})
)
}
/**
* Mount search sharing
*
* @param el - Search sharing element
* @param options - Options
*
* @returns Search sharing component observable
*/
export function mountSearchShare(
el: HTMLAnchorElement, options: MountOptions
): Observable<Component<SearchShare>> {
const push$ = new Subject<SearchShare>()
const done$ = push$.pipe(ignoreElements(), endWith(true))
push$.subscribe(({ url }) => {
el.setAttribute("data-clipboard-text", el.href)
el.href = `${url}`
})
/* Prevent following of link */
fromEvent(el, "click")
.pipe(
takeUntil(done$)
)
.subscribe(ev => ev.preventDefault())
/* Create and return component */
return watchSearchShare(el, options)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@@ -1,154 +0,0 @@
/*
* 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,
asyncScheduler,
combineLatestWith,
distinctUntilChanged,
filter,
finalize,
fromEvent,
map,
merge,
observeOn,
tap
} from "rxjs"
import { Keyboard } from "~/browser"
import {
SearchMessage,
SearchResult,
isSearchResultMessage
} from "~/integrations"
import { Component, getComponentElement } from "../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search suggestions
*/
export interface SearchSuggest {}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
keyboard$: Observable<Keyboard> /* Keyboard observable */
worker$: Subject<SearchMessage> /* Search worker */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount search suggestions
*
* This function will perform a lazy rendering of the search results, depending
* on the vertical offset of the search result container.
*
* @param el - Search result list element
* @param options - Options
*
* @returns Search result list component observable
*/
export function mountSearchSuggest(
el: HTMLElement, { worker$, keyboard$ }: MountOptions
): Observable<Component<SearchSuggest>> {
const push$ = new Subject<SearchResult>()
/* Retrieve query component and track all changes */
const query = getComponentElement("search-query")
const query$ = merge(
fromEvent(query, "keydown"),
fromEvent(query, "focus")
)
.pipe(
observeOn(asyncScheduler),
map(() => query.value),
distinctUntilChanged(),
)
/* Update search suggestions */
push$
.pipe(
combineLatestWith(query$),
map(([{ suggest }, value]) => {
const words = value.split(/([\s-]+)/)
if (suggest?.length && words[words.length - 1]) {
const last = suggest[suggest.length - 1]
if (last.startsWith(words[words.length - 1]))
words[words.length - 1] = last
} else {
words.length = 0
}
return words
})
)
.subscribe(words => el.innerHTML = words
.join("")
.replace(/\s/g, "&nbsp;")
)
/* Set up search keyboard handlers */
keyboard$
.pipe(
filter(({ mode }) => mode === "search")
)
.subscribe(key => {
switch (key.type) {
/* Right arrow: accept current suggestion */
case "ArrowRight":
if (
el.innerText.length &&
query.selectionStart === query.value.length
)
query.value = el.innerText
break
}
})
/* Filter search result message */
const result$ = worker$
.pipe(
filter(isSearchResultMessage),
map(({ data }) => data)
)
/* Create and return component */
return result$
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(() => ({ ref: el }))
)
}

View File

@@ -1,228 +0,0 @@
/*
* 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,
distinctUntilChanged,
endWith,
finalize,
first,
from,
fromEvent,
ignoreElements,
map,
mergeMap,
observeOn,
takeUntil,
tap,
withLatestFrom
} from "rxjs"
import {
Viewport,
getElement,
getElementOffset,
getElementSize,
getElements
} from "~/browser"
import { Component } from "../_"
import { Header } from "../header"
import { Main } from "../main"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Sidebar
*/
export interface Sidebar {
height: number /* Sidebar height */
locked: boolean /* Sidebar is locked */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
viewport$: Observable<Viewport> /* Viewport observable */
main$: Observable<Main> /* Main area observable */
}
/**
* Mount options
*/
interface MountOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
main$: Observable<Main> /* Main area observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch sidebar
*
* This function returns an observable that computes the visual parameters of
* the sidebar which depends on the vertical viewport offset, as well as the
* height of the main area. When the page is scrolled beyond the header, the
* sidebar is locked and fills the remaining space.
*
* @param el - Sidebar element
* @param options - Options
*
* @returns Sidebar observable
*/
export function watchSidebar(
el: HTMLElement, { viewport$, main$ }: WatchOptions
): Observable<Sidebar> {
const parent = el.closest<HTMLElement>(".md-grid")!
const adjust =
parent.offsetTop -
parent.parentElement!.offsetTop
/* Compute the sidebar's available height and if it should be locked */
return combineLatest([main$, viewport$])
.pipe(
map(([{ offset, height }, { offset: { y } }]) => {
height = height
+ Math.min(adjust, Math.max(0, y - offset))
- adjust
return {
height,
locked: y >= offset + adjust
}
}),
distinctUntilChanged((a, b) => (
a.height === b.height &&
a.locked === b.locked
))
)
}
/**
* Mount sidebar
*
* This function doesn't set the height of the actual sidebar, but of its first
* child the `.md-sidebar__scrollwrap` element in order to mitigiate jittery
* sidebars when the footer is scrolled into view. At some point we switched
* from `absolute` / `fixed` positioning to `sticky` positioning, significantly
* reducing jitter in some browsers (respectively Firefox and Safari) when
* scrolling from the top. However, top-aligned sticky positioning means that
* the sidebar snaps to the bottom when the end of the container is reached.
* This is what leads to the mentioned jitter, as the sidebar's height may be
* updated too slowly.
*
* This behaviour can be mitigiated by setting the height of the sidebar to `0`
* while preserving the padding, and the height on its first element.
*
* @param el - Sidebar element
* @param options - Options
*
* @returns Sidebar component observable
*/
export function mountSidebar(
el: HTMLElement, { header$, ...options }: MountOptions
): Observable<Component<Sidebar>> {
const inner = getElement(".md-sidebar__scrollwrap", el)
const { y } = getElementOffset(inner)
return defer(() => {
const push$ = new Subject<Sidebar>()
const done$ = push$.pipe(ignoreElements(), endWith(true))
const next$ = push$
.pipe(
auditTime(0, animationFrameScheduler)
)
/* Update sidebar height and offset */
next$.pipe(withLatestFrom(header$))
.subscribe({
/* Handle emission */
next([{ height }, { height: offset }]) {
inner.style.height = `${height - 2 * y}px`
el.style.top = `${offset}px`
},
/* Handle complete */
complete() {
inner.style.height = ""
el.style.top = ""
}
})
/* Bring active item into view on initial load */
next$.pipe(first())
.subscribe(() => {
for (const item of getElements(".md-nav__link--active[href]", el)) {
if (!item.clientHeight) // skip invisible toc in left sidebar
continue
const container = item.closest<HTMLElement>(".md-sidebar__scrollwrap")!
if (typeof container !== "undefined") {
const offset = item.offsetTop - container.offsetTop
const { height } = getElementSize(container)
container.scrollTo({
top: offset - height / 2
})
}
}
})
/* Handle accessibility for expandable items, see https://bit.ly/3jaod9p */
from(getElements<HTMLLabelElement>("label[tabindex]", el))
.pipe(
mergeMap(label => fromEvent(label, "click")
.pipe(
observeOn(asyncScheduler),
map(() => label),
takeUntil(done$)
)
)
)
.subscribe(label => {
const input = getElement<HTMLInputElement>(`[id="${label.htmlFor}"]`)
const nav = getElement(`[aria-labelledby="${label.id}"]`)
nav.setAttribute("aria-expanded", `${input.checked}`)
})
/* Create and return component */
return watchSidebar(el, options)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -1,142 +0,0 @@
/*
* 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,
catchError,
defer,
filter,
finalize,
map,
of,
shareReplay,
tap
} from "rxjs"
import { getElement } from "~/browser"
import { ConsentDefaults } from "~/components/consent"
import { renderSourceFacts } from "~/templates"
import {
Component,
getComponentElements
} from "../../_"
import {
SourceFacts,
fetchSourceFacts
} from "../facts"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Repository information
*/
export interface Source {
facts: SourceFacts /* Repository facts */
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Repository information observable
*/
let fetch$: Observable<Source>
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch repository information
*
* This function tries to read the repository facts from session storage, and
* if unsuccessful, fetches them from the underlying provider.
*
* @param el - Repository information element
*
* @returns Repository information observable
*/
export function watchSource(
el: HTMLAnchorElement
): Observable<Source> {
return fetch$ ||= defer(() => {
const cached = __md_get<SourceFacts>("__source", sessionStorage)
if (cached) {
return of(cached)
} else {
/* Check if consent is configured and was given */
const els = getComponentElements("consent")
if (els.length) {
const consent = __md_get<ConsentDefaults>("__consent")
if (!(consent && consent.github))
return EMPTY
}
/* Fetch repository facts */
return fetchSourceFacts(el.href)
.pipe(
tap(facts => __md_set("__source", facts, sessionStorage))
)
}
})
.pipe(
catchError(() => EMPTY),
filter(facts => Object.keys(facts).length > 0),
map(facts => ({ facts })),
shareReplay(1)
)
}
/**
* Mount repository information
*
* @param el - Repository information element
*
* @returns Repository information component observable
*/
export function mountSource(
el: HTMLAnchorElement
): Observable<Component<Source>> {
const inner = getElement(":scope > :last-child", el)
return defer(() => {
const push$ = new Subject<Source>()
push$.subscribe(({ facts }) => {
inner.appendChild(renderSourceFacts(facts))
inner.classList.add("md-source__repository--active")
})
/* Create and return component */
return watchSource(el)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -1,88 +0,0 @@
/*
* 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 } from "rxjs"
import { fetchSourceFactsFromGitHub } from "../github"
import { fetchSourceFactsFromGitLab } from "../gitlab"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Repository facts for repositories
*/
export interface RepositoryFacts {
stars?: number /* Number of stars */
forks?: number /* Number of forks */
version?: string /* Latest version */
}
/**
* Repository facts for organizations
*/
export interface OrganizationFacts {
repositories?: number /* Number of repositories */
}
/* ------------------------------------------------------------------------- */
/**
* Repository facts
*/
export type SourceFacts =
| RepositoryFacts
| OrganizationFacts
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Fetch repository facts
*
* @param url - Repository URL
*
* @returns Repository facts observable
*/
export function fetchSourceFacts(
url: string
): Observable<SourceFacts> {
/* Try to match GitHub repository */
let match = url.match(/^.+github\.com\/([^/]+)\/?([^/]+)?/i)
if (match) {
const [, user, repo] = match
return fetchSourceFactsFromGitHub(user, repo)
}
/* Try to match GitLab repository */
match = url.match(/^.+?([^/]*gitlab[^/]+)\/(.+?)\/?$/i)
if (match) {
const [, base, slug] = match
return fetchSourceFactsFromGitLab(base, slug)
}
/* Fallback */
return EMPTY
}

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