mirror of
https://github.com/squidfunk/mkdocs-material.git
synced 2026-03-31 06:33:51 -04:00
Merge Insiders features
This commit is contained in:
@@ -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 \
|
||||
&& \
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"origins": ["https://squidfunk.github.io"]
|
||||
}
|
||||
@@ -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
223
src/extensions/preview.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -273,6 +273,12 @@ class Category(View):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Profile view
|
||||
class Profile(View):
|
||||
pass
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Reference
|
||||
class Reference(Link):
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
19
src/plugins/optimize/__init__.py
Normal file
19
src/plugins/optimize/__init__.py
Normal 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.
|
||||
52
src/plugins/optimize/config.py
Normal file
52
src/plugins/optimize/config.py
Normal 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)
|
||||
388
src/plugins/optimize/plugin.py
Normal file
388
src/plugins/optimize/plugin.py
Normal 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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'"
|
||||
)
|
||||
|
||||
153
src/plugins/social/layout.py
Normal file
153
src/plugins/social/layout.py
Normal 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
29
src/plugins/social/templates/__init__.py
Normal file
29
src/plugins/social/templates/__init__.py
Normal 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 ""
|
||||
244
src/plugins/social/templates/default.yml
Normal file
244
src/plugins/social/templates/default.yml
Normal 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
|
||||
234
src/plugins/social/templates/default/accent.yml
Normal file
234
src/plugins/social/templates/default/accent.yml
Normal 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
|
||||
244
src/plugins/social/templates/default/invert.yml
Normal file
244
src/plugins/social/templates/default/invert.yml
Normal 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
|
||||
77
src/plugins/social/templates/default/only/image.yml
Normal file
77
src/plugins/social/templates/default/only/image.yml
Normal 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
|
||||
255
src/plugins/social/templates/default/variant.yml
Normal file
255
src/plugins/social/templates/default/variant.yml
Normal 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
|
||||
@@ -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 = {})
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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__(
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"rules": {
|
||||
"jsdoc/require-jsdoc": "off",
|
||||
"jsdoc/require-returns-check": "off"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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"))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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>()
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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 }))
|
||||
)
|
||||
}
|
||||
@@ -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$ }))
|
||||
)
|
||||
}
|
||||
@@ -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 }))
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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$
|
||||
}
|
||||
@@ -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 "./_"
|
||||
@@ -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 }))
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }))
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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 }))
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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$)))
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 }))
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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$)
|
||||
}
|
||||
@@ -21,4 +21,5 @@
|
||||
*/
|
||||
|
||||
export * from "./_"
|
||||
export * from "./content"
|
||||
export * from "./query"
|
||||
export * from "./result"
|
||||
@@ -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 })),
|
||||
)
|
||||
}
|
||||
@@ -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 }))
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
))
|
||||
)
|
||||
}
|
||||
@@ -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 }))
|
||||
)
|
||||
})
|
||||
}
|
||||
145
src/templates/assets/javascripts/components/parallax/index.ts
Normal file
145
src/templates/assets/javascripts/components/parallax/index.ts
Normal 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 }))
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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 }))
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-null/no-null": "off"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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 }))
|
||||
)
|
||||
}
|
||||
@@ -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 }))
|
||||
)
|
||||
}
|
||||
@@ -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, " ")
|
||||
)
|
||||
|
||||
/* 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 }))
|
||||
)
|
||||
}
|
||||
@@ -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 }))
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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 }))
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user