workspace:chore - initial commit

This commit is contained in:
squidfunk
2025-11-01 13:46:51 +01:00
commit a09c1439a8
122 changed files with 18388 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
templates
+28
View File
@@ -0,0 +1,28 @@
# Copyright (c) Zensical LLC <https://zensical.org>
# SPDX-License-Identifier: MIT
# Third-party contributions licensed under CLA
# 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 .zensical import *
__doc__ = zensical.__doc__
if hasattr(zensical, "__all__"):
__all__ = zensical.__all__
+22
View File
@@ -0,0 +1,22 @@
name: docs
on:
push:
branches:
- master
- main
permissions:
contents: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: 3.x
- run: pip install zensical
- run: zensical build
- uses: actions/upload-pages-artifact@v4
with:
path: site
- uses: actions/deploy-pages@v4
+172
View File
@@ -0,0 +1,172 @@
---
icon: lucide/hand
---
# Welcome to Zensical!
With Zensical, you can author professional documentation with minimal
fuss thanks to the *batteries included* approach.
These generated pages serve to showcase some of what you can do with Zensical
and to show you examples of using the various elements available to you as an
author. Links to the relevant documentation let you explore further.
The configuration file generated by `zensical new` can serve as a basis for your
own configuration. It contains plenty comments and links to the documentation.
## Admonitions
!!! note
This is a **note** admonition. Use it to provide helpful information.
!!! warning
This is a **warning** admonition. Be careful!
[Documentation ->](https://zensical.org/docs/authoring/admonitions)
### Collapsible Sections (Details)
??? info "Click to expand for more info"
This content is hidden until you click to expand it.
Great for FAQs or long explanations.
[Documentation ->](https://zensical.org/docs/authoring/admonitions/#collapsible-blocks)
## Buttons
[Subscribe to our newsletter](#buttons){ .md-button .md-button--primary }
[Documentation ->](https://zensical.org/docs/authoring/buttons)
## Code Blocks
Zensical uses [Pygments] to highlight code blocks.
[Pygments]: https://pygments.org/
```python linenums="1" hl_lines="2" title="Code blocks"
def greet(name):
print(f"Hello, {name}!") # (1)!
greet("Zensical")
```
1. :man_raising_hand: I'm a code annotation! I can contain `code`, __formatted
text__, images, ... basically anything that can be written in Markdown.
Code can also be highlighted inline: `#!python print("Hello from Python!")`.
[Documentation ->](http://localhost:8000/docs/authoring/code-blocks)
## Content tabs
=== "Python"
```python
print("Hello from Python!")
```
=== "JavaScript"
```javascript
console.log("Hello from JavaScript!");
```
[Documentation ->](https:///docs/authoring/content-tabs/)
## Diagrams
``` mermaid
graph LR
A[Start] --> B{Error?};
B -->|Yes| C[Hmm...];
C --> D[Debug];
D --> B;
B ---->|No| E[Yay!];
```
[Read the documentation](https://zensical.org/docs/authoring/diagrams)
## Footnotes
Here's a sentence with a footnote.[^1] Footnote tooltips are turned on,
so you can just hover over the footnote to see it.
[^1]: This is the footnote.
[Documentation ->](https://zensical.org/docs/authoring/footnotes)
## Formatting
- ==This was marked (highlight)==
- ^^This was inserted (underline)^^
- ~~This was deleted (strikethrough)~~
- H~2~O
- A^T^A
- ++ctrl+alt+del++
[Documentation ->](https://zensical.org/docs/authoring/formatting)
## Icons, Emojis
Zenscial supports GitHub-style emoji shortcodes:
* :sparkles: `:sparkles:`
* :rocket: `:rocket:`
* :tada: `:tada:`
* :memo: `:memo:`
* :eyes: `:eyes:`
[Documentation ->](https://zensical.org/docs/authoring/icons-emojis/)
## Maths
Mathematical formulae can be written using LaTeX syntax and rendered using
MathJax.
$$
\cos x=\sum_{k=0}^{\infty}\frac{(-1)^k}{(2k)!}x^{2k}
$$
!!! warning "Needs configuration"
Note that MathJax is included via a `script` tag on this page and is not
configured in the generated default configuration to avoid including it
in a pages that do not need it. See the documentation for details on how
to configure it on all your pages if they are more Maths-heavy than these
simple starter pages.
[Read the documentation](https://zensical.org/docs/authoring/math)
<script id="MathJax-script" async src="https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js"></script>
<script>
window.MathJax = {
tex: {
inlineMath: [["\\(", "\\)"]],
displayMath: [["\\[", "\\]"]],
processEscapes: true,
processEnvironments: true
},
options: {
ignoreHtmlClass: ".*|",
processHtmlClass: "arithmatex"
}
};
</script>
## Task Lists
* [x] Install Zensical
* [x] Configure `zensical.toml`
* [x] Write amazing documentation
* [ ] Deploy anywhere
[Documentation ->](https://zensical.org/docs/authoring/lists/#using-task-lists)
## Tooltips
[Hover me][example]
[example]: https://example.com "I'm a tooltip!"
[Documentation ->](https://zensical.org/docs/authoring/tooltips)
@@ -0,0 +1,49 @@
---
icon: simple/markdown
---
# Intro to Markdown
!!! warning ""
Zensical uses [Python Markdown] to be compatible with [Material for MkDocs].
In the medium term, we are considering adding support or CommonMark and
providing tools for existing projects to migrate. Check out our [roadmap].
[Python Markdown]: https://python-markdown.github.io/
[Material for MkDocs]: https://squidfunk.github.io/mkdocs-material/
[CommonMark]: https://commonmark.org/
[roadmap]: https://zensical.org/about/roadmap/
Text in Markdown can be _italicized_, __bold face__.
Markdown allows you to produce bullet point lists:
* bullet
* point
* list
* nested
* list
as well as numbered lists:
1. numbered
2. list
1. nested
2. list
> If you can't explain it to a six year old, you don't understand it
> yourself.<br> (Albert Einstein)
| Feature | Supported | Notes |
| -------------- | --------- | -------------------------- |
| Admonitions | ✅ | Native support |
| Code Highlight | ✅ | Pygments & Superfences |
| Task Lists | ✅ | Pymdown extensions |
| Emojis | ✅ | GitHub-style emoji |
<figure markdown="span">
![Image title](https://dummyimage.com/600x400/){ width="300" }
<figcaption>Image caption</figcaption>
</figure>
+312
View File
@@ -0,0 +1,312 @@
# ============================================================================
#
# The configuration produced by default is meant to highlight the features
# that Zensical provides and to serve as a starting point for your own
# projects.
#
# ============================================================================
[project]
# The site_name is shown in the page header and the browser window title
#
# Read more: https://zensical.org/docs/setup/basics#site_name
site_name = "My Zensical project"
# The site_description is included in the HTML head and should contain a
# meaningful description of the site content for use by search engines.
#
# Read more: https://zensical.org/docs/setup/basics#site_description
site_description = "A new project generated from the default template project."
# The site_author attribute. This is used in the HTML head element.
#
# Read more: https://zensical.org/docs/setup/basics#site_author
site_author = "<your name here>"
# The site_url is the canonical URL for your site. When building online
# documentation you should set this.
# Read more: https://zensical.org/docs/setup/basics#site_url
#site_url = "https://www.example.com/"
# The copyright notice appears in the page footer and can contain an HTML
# fragment.
#
# Read more: https://zensical.org/docs/setup/basics#copyright
copyright = """
(c) 2025 your name here
"""
# Zensical supports both implicit navigation and explicitly defined navigation.
# If you decide not to define a navigation here then Zensical will simply
# derive the navigation structure from the directory structure of your
# "docs_dir". The definition below demonstrates how a navigation structure
# can be defined using TOML syntax.
#
# Read more: https://zensical.org/docs/setup/setting-up-navigation
nav = [
{"Welcome" = "index.md"},
{"Intro to Markdown" = "markdown.md"},
]
# ----------------------------------------------------------------------------
# Section for configuring theme options
# ----------------------------------------------------------------------------
[project.theme]
# change this to "classic" to use the traditional Material for MkDocs look.
#
# TODO: add link to docs
variant = "modern"
# Zensical allows you to override specific blocks, partials, or whole
# templates as well as to define your own templates. To do this, uncomment
# the custom_dir setting below and set it to a directory in which you
# keep your template overrides.
#
# Read more:
# - https://zensical.org/docs/customization/#extending-the-theme
#
#custom_dir = "overrides"
# With the "favicon" option you can set your own image to use as the icon
# browsers will use in the browser title bar or tab bar. The path provided
# must be relative to the "docs_dir".
#
# Read more:
# - https://zensical.org/docs/setup/changing-the-logo-and-icons/#favicon
# - https://developer.mozilla.org/en-US/docs/Glossary/Favicon
#
#favicon = "assets/images/favicon.png"
# Zensical supports more than 60 different languages. This means that the
# labels and tooltips that Zensical's templates produce are translated.
# The "language" option allows you to set the language used. This language
# is also indicated in the HTML head element to help with accessibility
# and guide search engines and translation tools.
#
# The default language is "en" (English). It is possible to create
# sites with multiple languages and configure a language selector. See
# the documentation for details.
#
# Read more:
# - https://zensical.org/docs/docs/setup/changing-the-language/
#
language = "en"
# Zensical provides a number of feature toggles that change the behavior
# of the documentation site.
features = [
# Zensical includes an announcement bar. This feature allows users to
# dismiss it then they have read the announcement.
# https://zensical.org/docs/setup/setting-up-the-header/#announcement-bar
"announce.dismiss",
# If you have a repository configured and turn feature this on, Zensical
# will generate an edit button for the page. This works for common
# repository hosting services.
# https://zensical.org/docs/setup/adding-a-git-repository/#code-actions
#"content.action.edit",
# If you have a repository configured and turn feature this on, Zensical
# will generate a button that allows the user to view the Markdown
# code for the current page.
# https://zensical.org/docs/setup/adding-a-git-repository/#code-actions
#"content.action.view",
# Code annotations allow you to add an icon with a tooltip to your
# code blocks to provide explanations at crucial points.
# https://zensical.org/docs/authoring/code-blocks/#code-annotations
"content.code.annotate",
# This feature turns on a button in code blocks that allow users to
# copy the content to their clipboard without first selecting it.
# https://zensical.org/docs/authoring/code-blocks/#code-copy-button
"content.code.copy",
# Code blocks can include a button to allow for the selection of line
# ranges by the user.
# https://zensical.org/docs/authoring/code-blocks/#code-selection-button
"content.code.select",
# Zensical can render footnotes as inline tooltips, so the user can read
# the footnote without leaving the context of the document.
# https://zensical.org/docs/authoring/footnotes/#footnote-tooltips
"content.footnote.tooltips",
# If you have many content tabs that have the same titles (e.g., "Python",
# "JavaScript", "Cobol"), this feature causes all of them to switch to
# at the same time when the user chooses their language in one.
# https://zensical.org/docs/authoring/content-tabs/#linked-content-tabs
"content.tabs.link",
# TODO: not sure I understand this one? Is there a demo of this in the docs?
# https://zensical.org/docs/authoring/tooltips/#improved-tooltips
"content.tooltips",
# With this feature enabled, Zensical will automatically hide parts
# of the header when the user scrolls past a certain point.
# https://zensical.org/docs/setup/setting-up-the-header/#automatic-hiding
"header.autohide",
# Turn on this feature to expand all collapsible sections in the
# navigation sidebar by default.
# https://zensical.org/docs/setup/setting-up-navigation/#navigation-expansion
# "navigation.expand",
# This feature turns on navigation elements in the footer that allow the
# user to navigate to a next or previous page.
# http://zensical.org/docs/setup/setting-up-the-footer/#navigation
"navigation.footer",
# When section index pages are enabled, documents can be directly attached
# to sections, which is particularly useful for providing overview pages.
# http://zensical.org/docs/setup/setting-up-navigation/#section-index-pages
"navigation.indexes",
# When instant loading is enabled, clicks on all internal links will be
# intercepted and dispatched via XHR without fully reloading the page.
# http://zensical.org/docs/setup/setting-up-navigation/#instant-loading
"navigation.instant",
# With instant prefetching, your site will start to fetch a page once the
# user hovers over a link. This will reduce the perceived loading time
# for the user.
# http://zensical.org/docs/setup/setting-up-navigation/#instant-prefetching
"navigation.instant.prefetch",
# In order to provide a better user experience on slow connections when
# using instant navigation, a progress indicator can be enabled.
# http://zensical.org/docs/setup/setting-up-navigation/#progress-indicator
#"navigation.instant.progress"
# When navigation paths are activated, a breadcrumb navigation is rendered
# above the title of each page
# http://zensical.org/docs/setup/setting-up-navigation/#navigation-path
"navigation.path",
# When pruning is enabled, only the visible navigation items are included
# in the rendered HTML, reducing the size of the built site by 33% or more.
# http://zensical.org/docs/setup/setting-up-navigation/#navigation-pruning
#"navigation.prune",
# When sections are enabled, top-level sections are rendered as groups in
# the sidebar for viewports above 1220px, but remain as-is on mobile.
# http://zensical.org/docs/setup/setting-up-navigation/#navigation-sections
"navigation.sections",
# When tabs are enabled, top-level sections are rendered in a menu layer
# below the header for viewports above 1220px, but remain as-is on mobile.
# http://zensical.org/docs/setup/setting-up-navigation/#navigation-tabs
#"navigation.tabs",
# When sticky tabs are enabled, navigation tabs will lock below the header
# and always remain visible when scrolling down.
# http://zensical.org/docs/setup/setting-up-navigation/#sticky-navigation-tabs
#"navigation.tabs.sticky",
# A back-to-top button can be shown when the user, after scrolling down,
# starts to scroll up again.
# http://zensical.org/docs/setup/setting-up-navigation/#back-to-top-button
"navigation.top",
# When anchor tracking is enabled, the URL in the address bar is
# automatically updated with the active anchor as highlighted in the table
# of contents.
# http://zensical.org/docs/setup/setting-up-navigation/#anchor-tracking
"navigation.tracking",
# When search highlighting is enabled and a user clicks on a search result,
# Zensical will highlight all occurrences after following the link.
# https://zensical.org/docs/setup/setting-up-site-search/#search-highlighting
"search.highlight",
# When anchor following for the table of contents is enabled, the sidebar
# is automatically scrolled so that the active anchor is always visible.
# https://zensical.org/docs/setup/setting-up-navigation/#anchor-following
# "toc.follow"
# When navigation integration for the table of contents is enabled, it is
# always rendered as part of the navigation sidebar on the left.
# https://zensical.org/docs/setup/setting-up-navigation/#navigation-integration
#"toc.integrate"
]
# With the "extra_css" option you can add your own CSS styling to customize
# your Zensical project according to your needs. You can add any number of
# CSS files.
#
# The path provided should be relative to the "docs_dir".
#
# Read more: http://zensical.org/docs/customization/#additional-css
#
#extra_css = ["assets/stylesheets/extra.css"]
# With the `extra_javascript` option you can add your own JavaScript to your
# project to customize the behavior according to your needs.
#
# The path provided should be relative to the "docs_dir".
#
# Read more: http://zensical.org/docs/customization/#additional-javascript
#extra_javascript = ["assets/javascript/extra.js"]
# ----------------------------------------------------------------------------
# In the "palette" subsection you can configure options for the color scheme.
# You can configure different color # schemes, e.g., to turn on dark mode,
# that the user can switch between. Each color scheme can be further
# customized.
#
# Read more:
# - https://zensical.org/docs/setup/changing-the-colors
# ----------------------------------------------------------------------------
# TODO: use the version from "web"
[[project.theme.palette]]
scheme = "default"
toggle.icon = "lucide/sun"
toggle.name = "Switch to dark mode"
[[project.theme.palette]]
scheme = "slate"
toggle.icon = "lucide/moon"
toggle.name = "Switch to light mode"
# ----------------------------------------------------------------------------
# In the "font" subsection you can configure the fonts used. By default, fonts
# are loaded from Google Fonts, giving you a wide range of choices from a set
# of suitably licensed fonts. There are options for a normal text font and for
# a monospaced font used in code blocks.
# ----------------------------------------------------------------------------
# These are the defaults
#[project.theme.font]
#text = "Inter"
#code = "Jetbrains Mono"
# ----------------------------------------------------------------------------
# You can configure your own logo to be shown in the header using the "logo"
# option in the "icons" subsection. The logo can be a path to a file in your
# "docs_dir" or it can be a path to an icon.
#
# Likewise, you can customize the logo used for the repository section of the
# header. Zensical derives the default logo for this from the repository URL.
# See below...
#
# There are other icons you can customize. See the documentation for details.
#
# Read more:
# - https://zensical.org/docs/setup/changing-the-logo-and-icons
# - http://zensical.org/docs/authoring/icons-emojis/#search
# ----------------------------------------------------------------------------
#[project.theme.icon]
#logo = "lucide/smile"
#repo = "lucide/smile"
# ----------------------------------------------------------------------------
# The "extra" section contains miscellaneous settings.
# ----------------------------------------------------------------------------
#[[project.extra.social]]
#icon = "fontawesome/brands/github"
#link = "https://github.com/user/repo"
+770
View File
@@ -0,0 +1,770 @@
# Copyright (c) Zensical LLC <https://zensical.org>
# SPDX-License-Identifier: MIT
# Third-party contributions licensed under CLA
# 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 importlib
import os
import tomllib
import yaml
from click import ClickException
from deepmerge import always_merger
from functools import partial
from typing import Any, IO
from yaml import BaseLoader, Loader, YAMLError
from yaml.constructor import ConstructorError
from urllib.parse import urlparse
from .extensions.emoji import to_svg, twemoji
# ----------------------------------------------------------------------------
# Globals
# ----------------------------------------------------------------------------
_CONFIG = None
"""
Global configuration to pick up later for parsing Markdown.
Since MkDocs uses YAML as a configuration format, the configuration can contain
references to functions or other Python objects, for which we don't have any
representation in Rust. Thus, we just keep the configuration on the Python
side, and use it directly when needed. It's a hack but will do for now.
"""
# ----------------------------------------------------------------------------
# Classes
# ----------------------------------------------------------------------------
class ConfigurationError(ClickException):
"""
Configuration resolution or validation failed.
"""
# ----------------------------------------------------------------------------
# Functions
# ----------------------------------------------------------------------------
def parse_config(path: str) -> dict:
"""
Parse configuration file.
"""
if path.endswith("zensical.toml"):
return parse_zensical_config(path)
else:
return parse_mkdocs_config(path)
def parse_zensical_config(path: str) -> dict:
"""
Parse zensical.toml configuration file.
"""
global _CONFIG
with open(path, "rb") as f:
config = tomllib.load(f)
if "project" in config:
config = config["project"]
# Apply defaults and return parsed configuration
_CONFIG = _apply_defaults(config, path)
return _CONFIG
def parse_mkdocs_config(path: str) -> dict:
"""
Parse mkdocs.yml configuration file.
"""
global _CONFIG
with open(path, "r") as f:
config = _yaml_load(f)
# Apply defaults and return parsed configuration
_CONFIG = _apply_defaults(config, path)
return _CONFIG
def get_theme_dir() -> str:
"""
Return the theme directory.
"""
path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(path, "templates")
def _apply_defaults(config: dict, path: str) -> dict:
"""
Apply default settings in configuration.
Note that this is loosely based on the defaults that MkDocs sets in its own
configuration system, which we won't port for compatibility right now, as
well as the defaults that are set in Material for MkDocs for theme- and
extra-level settings.
We must set all properties, as well as nested properties to `None`, or PyO3
will refuse to convert them, as the key must definitely exist.
"""
if "site_name" not in config:
raise ConfigurationError("Missing required setting: site_name")
# Set site directory
config.setdefault("site_dir", "site")
if ".." in config.get("site_dir"):
raise ConfigurationError("site_dir must not contain '..'")
# Set docs directory
config.setdefault("docs_dir", "docs")
if ".." in config.get("docs_dir"):
raise ConfigurationError("docs_dir must not contain '..'")
# Set defaults for core settings
set_default(config, "site_url", None, str)
set_default(config, "site_description", None, str)
set_default(config, "site_author", None, str)
set_default(config, "use_directory_urls", True, bool)
set_default(config, "dev_addr", "localhost:8000", str)
set_default(config, "copyright", None, str)
# Set defaults for repository settings
set_default(config, "repo_url", None, str)
set_default(config, "repo_name", None, str)
set_default(config, "edit_uri_template", None, str)
set_default(config, "edit_uri", None, str)
# Set defaults for repository name settings
repo_url = config.get("repo_url")
if repo_url and not config.get("repo_name"):
host = urlparse(repo_url).hostname or ""
if host == "github.com":
config["repo_name"] = "GitHub"
config["edit_uri"] = "edit/master/docs"
elif host == "gitlab.com":
config["repo_name"] = "GitLab"
config["edit_uri"] = "edit/master/docs"
elif host == "bitbucket.org":
config["repo_name"] = "Bitbucket"
config["edit_uri"] = "src/default/docs"
elif host:
config["repo_name"] = host.split(".")[0].title()
# Remove trailing slash from edit_uri if present
edit_uri = config.get("edit_uri")
if isinstance(edit_uri, str) and edit_uri.endswith("/"):
config["edit_uri"] = edit_uri.rstrip("/")
# Set defaults for theme font settings
theme = config.setdefault("theme", {})
if isinstance(theme, str):
theme = {"name": theme}
config["theme"] = theme
# Set variant and fonts for variant
set_default(theme, "variant", "modern", str)
if theme.get("variant") == "modern":
font = {"text": "Inter", "code": "JetBrains Mono"}
else:
font = {"text": "Roboto", "code": "Roboto Mono"}
# Resolve custom theme directory
set_default(theme, "custom_dir", None, str)
if theme.get("custom_dir"):
theme["custom_dir"] = os.path.join(
os.path.dirname(path), theme["custom_dir"]
)
# Ensure presence of static templates
theme["static_templates"] = ["404.html", "sitemap.xml"]
# Set defaults for theme settings
set_default(theme, "language", "en", str)
set_default(theme, "direction", None, str)
set_default(theme, "features", [], list)
set_default(theme, "favicon", "assets/images/favicon.png", str)
set_default(theme, "logo", None, str)
# Set defaults for theme font settings
theme.setdefault("font", {})
if isinstance(theme["font"], dict):
set_default(theme["font"], "text", font["text"], str)
set_default(theme["font"], "code", font["code"], str)
# Set defaults for theme icons
icon = theme.setdefault("icon", {})
set_default(icon, "repo", None, str)
set_default(icon, "annotation", None, str)
set_default(icon, "tag", {}, dict)
if theme.get("variant") == "modern":
set_default(icon, "logo", "lucide/book-open", str)
set_default(icon, "edit", "lucide/file-pen", str)
set_default(icon, "view", "lucide/file-code-2", str)
set_default(icon, "top", "lucide/circle-arrow-up", str)
set_default(icon, "share", "lucide/share-2", str)
set_default(icon, "menu", "lucide/menu", str)
set_default(icon, "alternate", "lucide/languages", str)
set_default(icon, "search", "lucide/search", str)
set_default(icon, "close", "lucide/x", str)
set_default(icon, "previous", "lucide/arrow-left", str)
set_default(icon, "next", "lucide/arrow-right", str)
else:
set_default(icon, "logo", None, str)
set_default(icon, "edit", None, str)
set_default(icon, "view", None, str)
set_default(icon, "top", None, str)
set_default(icon, "share", None, str)
set_default(icon, "menu", None, str)
set_default(icon, "alternate", None, str)
set_default(icon, "search", None, str)
set_default(icon, "close", None, str)
set_default(icon, "previous", None, str)
set_default(icon, "next", None, str)
# Set defaults for theme admonition icons
admonition = icon.setdefault("admonition", {})
set_default(admonition, "note", None, str)
set_default(admonition, "abstract", None, str)
set_default(admonition, "info", None, str)
set_default(admonition, "tip", None, str)
set_default(admonition, "success", None, str)
set_default(admonition, "question", None, str)
set_default(admonition, "warning", None, str)
set_default(admonition, "failure", None, str)
set_default(admonition, "danger", None, str)
set_default(admonition, "bug", None, str)
set_default(admonition, "example", None, str)
set_default(admonition, "quote", None, str)
# Set defaults for theme palette settings and normalize to list
palette = theme.setdefault("palette", [])
if isinstance(palette, dict):
palette = [palette]
theme["palette"] = palette
# Set defaults for each palette entry
for entry in palette:
set_default(entry, "media", None, str)
set_default(entry, "scheme", None, str)
set_default(entry, "primary", None, str)
set_default(entry, "accent", None, str)
set_default(entry, "toggle", None, dict)
# Set defaults for palette toggle
toggle = entry.get("toggle")
if toggle:
set_default(toggle, "icon", None, str)
set_default(toggle, "name", None, str)
# Set defaults for extra settings
extra = config.setdefault("extra", {})
set_default(extra, "homepage", None, str)
set_default(extra, "scope", None, str)
set_default(extra, "annotate", {}, dict)
set_default(extra, "tags", {}, dict)
set_default(extra, "generator", True, bool)
set_default(extra, "polyfills", [], list)
set_default(extra, "analytics", None, dict)
# Set defaults for extra analytics settings
analytics = extra.get("analytics")
if analytics:
set_default(analytics, "provider", None, str)
set_default(analytics, "property", None, str)
set_default(analytics, "feedback", None, dict)
# Set defaults for extra analytics feedback settings
feedback = analytics.get("feedback")
if feedback:
set_default(feedback, "title", None, str)
set_default(feedback, "ratings", [], list)
# Set defaults for each rating entry
ratings = feedback.setdefault("ratings", [])
for entry in ratings:
set_default(entry, "icon", None, str)
set_default(entry, "name", None, str)
set_default(entry, "data", None, str)
set_default(entry, "note", None, str)
# Set defaults for extra consent settings
consent = extra.setdefault("consent", None)
if consent:
set_default(consent, "title", None, str)
set_default(consent, "description", None, str)
set_default(consent, "actions", [], list)
# Set defaults for extra consent cookie settings
cookies = consent.setdefault("cookies", {})
for key, value in cookies.items():
if isinstance(value, str):
cookies[key] = {"name": value, "checked": False}
# Set defaults for each cookie entry
set_default(cookies[key], "name", None, str)
set_default(cookies[key], "checked", False, bool)
# Set defaults for extra social settings
social = extra.setdefault("social", [])
for entry in social:
set_default(entry, "icon", None, str)
set_default(entry, "name", None, str)
set_default(entry, "link", None, str)
# Set defaults for extra alternate settings
alternate = extra.setdefault("alternate", [])
for entry in alternate:
set_default(entry, "name", None, str)
set_default(entry, "link", None, str)
set_default(entry, "lang", None, str)
# Set defaults for extra version settings
version = extra.setdefault("version", None)
if version:
set_default(version, "provider", None, str)
set_default(version, "default", None, str)
set_default(version, "alias", False, bool)
# Ensure all non-existent values are all empty strings (for now)
config["extra"] = _convert_extra(extra)
# Set defaults for extra files
set_default(config, "extra_css", [], list)
set_default(config, "extra_templates", [], list)
# Generate navigation if not defined, and convert
config["nav"] = _convert_nav(config.setdefault("nav", []))
config["extra_javascript"] = _convert_extra_javascript(
config.setdefault("extra_javascript", [])
)
# MkDocs will also set fenced_code, which is incompatible with SuperFences,
# the extension that Material for MkDocs generally recommends. Note that we
# decided to set defaults that make it easy to get started with sensible
# Markdown support, but users can override this as needed.
markdown_extensions, mdx_configs = _convert_markdown_extensions(
config.get(
"markdown_extensions",
{
"abbr": {},
"admonition": {},
"attr_list": {},
"def_list": {},
"footnotes": {},
"md_in_html": {},
"toc": {"permalink": True},
"pymdownx.arithmatex": {"generic": True},
"pymdownx.betterem": {"smart_enable": "all"},
"pymdownx.caret": {},
"pymdownx.details": {},
"pymdownx.emoji": {
"emoji_generator": to_svg,
"emoji_index": twemoji,
},
"pymdownx.highlight": {
"anchor_linenums": True,
"line_spans": "__span",
"pygments_lang_class": True,
},
"pymdownx.inlinehilite": {},
"pymdownx.keys": {},
"pymdownx.magiclink": {},
"pymdownx.mark": {},
"pymdownx.smartsymbols": {},
"pymdownx.superfences": {
"custom_fences": [{"name": "mermaid", "class": "mermaid"}]
},
"pymdownx.tabbed": {
"alternate_style": True,
"combine_header_slug": True,
},
"pymdownx.tasklist": {"custom_checkbox": True},
"pymdownx.tilde": {},
},
)
)
config["markdown_extensions"] = markdown_extensions
config["mdx_configs"] = mdx_configs
# Now, since YAML supports using Python tags to resolve functions, we need
# to support the same for when we load TOML. This is a bandaid, and we will
# find a better solution, once we work on configuration management, but for
# now this should be sufficient.
emoji = config["mdx_configs"].get("pymdownx.emoji", {})
if isinstance(emoji.get("emoji_generator"), str):
emoji["emoji_generator"] = _resolve(emoji.get("emoji_generator"))
if isinstance(emoji.get("emoji_index"), str):
emoji["emoji_index"] = _resolve(emoji.get("emoji_index"))
# Tabbed extension configuration - we need to resolve the slugification
# function.
tabbed = config["mdx_configs"].get("pymdownx.tabbed", {})
if isinstance(tabbed.get("slugify"), dict):
object = tabbed["slugify"].get("object", "pymdownx.slugs.slugify")
tabbed["slugify"] = partial(
_resolve(object), tabbed["slugify"].get("kwds")
)
# Ensure the table of contents title is initialized, as it's used inside
# the template, and the table of contents extension is always defined
config["mdx_configs"]["toc"].setdefault("title", None)
# Convert plugins configuration
config["plugins"] = _convert_plugins(config.get("plugins", []), config)
return config
def set_default(
entry: dict, key: str, default: Any, data_type: type = None
) -> None:
"""
Set a key to a default value if it isn't set, and optionally cast it to the specified data type.
"""
# Set the default value if the key is not present
entry.setdefault(key, default)
# Optionally cast the value to the specified data type
if data_type is not None and entry[key] is not None:
try:
entry[key] = data_type(entry[key])
except (ValueError, TypeError) as e:
raise ValueError(f"Failed to cast key '{key}' to {data_type}: {e}")
def _convert_extra(data: dict | list) -> dict | list:
"""
Recursively convert all None values in a dictionary or list to empty strings.
"""
if isinstance(data, dict):
# Process each key-value pair in the dictionary
return {
key: _convert_extra(value)
if isinstance(value, (dict, list))
else ("" if value is None else value)
for key, value in data.items()
}
elif isinstance(data, list):
# Process each item in the list
return [
_convert_extra(item)
if isinstance(item, (dict, list))
else ("" if item is None else item)
for item in data
]
else:
return data
def _resolve(symbol: str):
"""
Resolve a symbol to its corresponding Python object.
"""
module_path, func_name = symbol.rsplit(".", 1)
module = importlib.import_module(module_path)
return getattr(module, func_name)
# -----------------------------------------------------------------------------
def _convert_nav(nav: dict) -> dict:
"""
Convert MkDocs navigation
"""
return [_convert_nav_item(entry) for entry in nav]
def _convert_nav_item(item: str | dict | list) -> dict:
"""
Convert MkDocs shorthand navigation structure into something more manageable
as we need to annotate each item with a title, URL, icon, and children.
"""
if isinstance(item, str):
return {
"title": None,
"url": item,
"canonical_url": None,
"meta": None,
"children": [],
"is_index": _is_index(item),
"active": False,
}
# Handle Title: URL
elif isinstance(item, dict):
for title, value in item.items():
if isinstance(value, str):
return {
"title": str(title),
"url": value,
"canonical_url": None,
"meta": None,
"children": [],
"is_index": _is_index(value),
"active": False,
}
elif isinstance(value, list):
return {
"title": str(title),
"url": None,
"canonical_url": None,
"meta": None,
"children": [_convert_nav_item(child) for child in value],
"is_index": False,
"active": False,
}
# Handle a list of items
elif isinstance(item, list):
return [_convert_nav_item(child) for child in item]
else:
raise ValueError(f"Unknown nav item type: {type(item)}")
def _is_index(path: str) -> bool:
"""
Returns, whether the given path points to a section index.
"""
return path.endswith(("index.md", "README.md"))
# -----------------------------------------------------------------------------
def _convert_extra_javascript(value: list[any]) -> list:
"""
Ensure extra_javascript uses a structured format.
"""
for i, item in enumerate(value):
if isinstance(item, str):
value[i] = {
"path": item,
"type": None,
"async": False,
"defer": False,
}
elif isinstance(item, dict):
item.setdefault("path", "")
item.setdefault("type", None)
item.setdefault("async", False)
item.setdefault("defer", False)
else:
raise ValueError(
f"Unknown extra_javascript item type: {type(item)}"
)
# Return resulting value
return value
# -----------------------------------------------------------------------------
def _convert_markdown_extensions(value: any):
"""
Convert Markdown extensions configuration to what Python Markdown expects.
"""
markdown_extensions = ["toc", "tables"]
mdx_configs = {"toc": {}, "tables": {}}
# In case of Python Markdown Extensions, we allow to omit the necessary
# quotes around the extension names, so we need to hoist the extensions
# configuration one level up. This is a pre-processing step before we
# actually parse the configuration.
if "pymdownx" in value:
pymdownx = value.pop("pymdownx")
for ext, config in pymdownx.items():
value[f"pymdownx.{ext}"] = config
# Extensions can be defined as a dict
if isinstance(value, dict):
for ext, config in value.items():
markdown_extensions.append(ext)
mdx_configs[ext] = config or {}
# Extensions can also be defined as a list
else:
for item in value:
if isinstance(item, dict):
ext, config = item.popitem()
markdown_extensions.append(ext)
mdx_configs[ext] = config or {}
elif isinstance(item, str):
markdown_extensions.append(item)
# Return extension list and configuration, after ensuring they're unique
return list(set(markdown_extensions)), mdx_configs
# ----------------------------------------------------------------------------
def _convert_plugins(value: any, config: dict) -> list:
"""
Convert plugins configuration to something we can work with.
"""
plugins = {}
# Plugins can be defined as a dict
if isinstance(value, dict):
for name, data in value.items():
plugins[name] = data
# Plugins can also be defined as a list
else:
for item in value:
if isinstance(item, dict):
name, data = item.popitem()
plugins[name] = data
elif isinstance(item, str):
plugins[item] = {}
# Define defaults for search plugin
search = plugins.setdefault("search", {})
search.setdefault("enabled", True)
search.setdefault("separator", '[\\s\\-_,:!=\\[\\]()\\\\"`/]+|\\.(?!\\d)')
# Define defaults for offline plugin
offline = plugins.setdefault("offline", {"enabled": False})
offline.setdefault("enabled", True)
# Ensure correct resolution of links when viewing the site from the
# file system by disabling directory URLs
if offline.get("enabled"):
config["use_directory_urls"] = False
# Append iframe-worker to polyfills/shims
if not any(
"iframe-worker" in url for url in config["extra"]["polyfills"]
):
script = "https://unpkg.com/iframe-worker/shim"
config["extra"]["polyfills"].append(script)
# Ensure extra polyfills/shims use structured format
config["extra"]["polyfills"] = _convert_extra_javascript(
config["extra"]["polyfills"]
)
# Now, add another level of indirection, by moving all plugin configuration
# into a `config` property, making it compatible with Material for MkDocs.
for name, config in plugins.items():
if not isinstance(config, dict) or "config" not in config:
plugins[name] = {"config": config}
# Return plugins
return plugins
# ----------------------------------------------------------------------------
def _yaml_load(
source: IO, loader: type[BaseLoader] | None = None
) -> dict[str, Any]:
"""
Load configuration file and resolve environment variables and parent files.
Note that INHERIT is only a bandaid that was introduced to allow for some
degree of modularity, but with serious shortcomings. Zensical will use a
different approach in the future, which will allow for composable and
environment-specific configuration.
"""
loader = loader or Loader.add_constructor("!ENV", _construct_env_tag)
try:
config = yaml.load(
# Compatibility shim: we remap Material's extension namespace to
# Zensical's, and the now deprecated materialx namespace as well
source.read()
.replace("material.extensions", "zensical.extensions")
.replace("materialx", "zensical.extensions"),
Loader=Loader,
)
except YAMLError as e:
raise ConfigurationError(
f"Encountered an error parsing the configuration file: {e}"
)
if config is None:
return {}
# Try to resolve inherited configuration file
if "INHERIT" in config and not isinstance(source, str):
relpath = config.pop("INHERIT")
abspath = os.path.normpath(
os.path.join(os.path.dirname(source.name), relpath)
)
if not os.path.exists(abspath):
raise ConfigurationError(
f"Inherited config file '{relpath}' doesn't exist at '{abspath}'."
)
with open(abspath, "r") as fd:
parent = _yaml_load(fd, loader)
config = always_merger.merge(parent, config)
# Return resulting configuration
return config
def _construct_env_tag(loader: yaml.Loader, node: yaml.Node):
"""
Assign value of ENV variable referenced at node.
MkDocs supports the use of !ENV to reference environment variables in YAML
configuration files. We won't likely support this in Zensical, but for now
we need it to build MkDocs projects. Zensical will use a different approach
to create environment-specific configuration in the future.
Licensed under MIT
Copyright (c) 2020 Waylan Limberg
Taken and adapted from
https://github.com/waylan/pyyaml-env-tag/blob/master/yaml_env_tag.py
"""
default = None
# Handle !ENV <name>
if isinstance(node, yaml.nodes.ScalarNode):
vars = [loader.construct_scalar(node)]
# Handle !ENV [<name>, <fallback>]
elif isinstance(node, yaml.nodes.SequenceNode):
child_nodes = node.value
if len(child_nodes) > 1:
default = loader.construct_object(child_nodes[-1])
child_nodes = child_nodes[:-1]
# Env Vars are resolved as string values, ignoring (implicit) types.
vars = [loader.construct_scalar(child) for child in child_nodes]
else:
raise ConstructorError(
context=f"expected a scalar or sequence node, but found {node.id}",
start_mark=node.start_mark,
)
# Resolve environment variable
for var in vars:
if var in os.environ:
value = os.environ[var]
# Resolve value to Python type using YAML's implicit resolvers
tag = loader.resolve(yaml.nodes.ScalarNode, value, (True, False))
return loader.construct_object(yaml.nodes.ScalarNode(tag, value))
# Otherwise return default
return default
+22
View File
@@ -0,0 +1,22 @@
# Copyright (c) Zensical LLC <https://zensical.org>
# SPDX-License-Identifier: MIT
# Third-party contributions licensed under CLA
# 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.
+119
View File
@@ -0,0 +1,119 @@
# Copyright (c) Zensical LLC <https://zensical.org>
# SPDX-License-Identifier: MIT
# Third-party contributions licensed under CLA
# 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 codecs
import functools
import os
from glob import iglob
from markdown import Markdown
from pymdownx import emoji, twemoji_db
from xml.etree.ElementTree import Element
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def twemoji(options: object, md: Markdown):
"""
Create twemoji index.
"""
paths = options.get("custom_icons", [])[:]
return _load_twemoji_index(tuple(paths))
def to_svg(
index: str,
shortname: str,
alias: str,
uc: str | None,
alt: str,
title: str,
category: str,
options: object,
md: Markdown,
):
"""
Load icon.
"""
if not uc:
icons = md.inlinePatterns["emoji"].emoji_index["emoji"]
# Create and return element to host icon
el = Element("span", {"class": options.get("classes", index)})
el.text = md.htmlStash.store(_load(icons[shortname]["path"]))
return el
# Delegate to `pymdownx.emoji` extension
return emoji.to_svg(
index, shortname, alias, uc, alt, title, category, options, md
)
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
@functools.lru_cache(maxsize=None)
def _load(file: str):
"""
Load icon from file.
"""
with codecs.open(file, encoding="utf-8") as f:
return f.read()
@functools.lru_cache(maxsize=None)
def _load_twemoji_index(paths):
"""
Load twemoji index and add icons.
"""
index = {
"name": "twemoji",
"emoji": twemoji_db.emoji,
"aliases": twemoji_db.aliases,
}
# Compute path to theme root and traverse all icon directories
root = os.path.dirname(os.path.dirname(__file__))
root = os.path.join(root, "templates", ".icons")
for path in [*paths, root]:
base = os.path.normpath(path)
# Index icons provided by the theme and via custom icons
glob = os.path.join(base, "**", "*.svg")
glob = iglob(os.path.normpath(glob), recursive=True)
for file in glob:
icon = file[len(base) + 1 : -4].replace(os.path.sep, "-")
# Add icon to index
name = f":{icon}:"
if not any(name in index[key] for key in ["emoji", "aliases"]):
index["emoji"][name] = {"name": name, "path": file}
# Return index
return index
+126
View File
@@ -0,0 +1,126 @@
# Copyright (c) Zensical LLC <https://zensical.org>
# SPDX-License-Identifier: MIT
# Third-party contributions licensed under CLA
# 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
from markdown import Extension, Markdown
from markdown.treeprocessors import Treeprocessor
from markdown.util import AMP_SUBSTITUTE
from xml.etree.ElementTree import Element
from urllib.parse import urlparse
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class LinksProcessor(Treeprocessor):
"""
Tree processor to replace links in Markdown with URLs.
Note that we view this as a bandaid until we can do processing on proper
HTML ASTs in Rust. In the meantime, we just replace them as we find them.
This processor will replace links to other Markdown files, as well as
adjust asset links if directory URLs are used.
"""
def __init__(self, md: Markdown, path: str, use_directory_urls: bool):
super().__init__(md)
self.path = path # Current page
self.use_directory_urls = use_directory_urls
def run(self, root: Element):
# Now, we determine whether the current page is an index page, as we
# must apply slightly different handling in case of directory URLs
current_is_index = self.path.endswith(("index.md", "README.md"))
for el in root.iter():
# In case the element has a `href` or `src` attribute, we parse it
# as an URL, so we can analyze and alter its path
key = next((k for k in ("href", "src") if el.get(k)), None)
if not key:
continue
# Extract value - Python Markdown does some weird stuff where it
# replaces mailto: links with double encoded entities. MkDocs just
# skips if it detects that, so we do the same.
value = el.get(key)
if AMP_SUBSTITUTE in value:
continue
# Parse URL and skip everything that is not a relative link
url = urlparse(value)
if url.scheme or url.netloc:
continue
# Leave anchors that go to the same page as they are
if not url.path and url.fragment:
continue
# Now, adjust relative links to Markdown files
path = url.path
if path.endswith(".md"):
path = path.removesuffix(".md") + ".html"
if self.use_directory_urls:
if path.endswith("index.html"):
path = path[: -len("index.html")]
elif path.endswith("README.html"):
path = path[: -len("README.html")]
elif path.endswith(".html"):
path = path[: -len(".html")] + "/"
# If the current page is not an index page, and we should render
# directory URLs, we need to prepend a "../" to all links
if not current_is_index and self.use_directory_urls:
path = f"../{path}"
# Reassemble URL and update link
el.set(key, url._replace(path=path).geturl())
# -----------------------------------------------------------------------------
class LinksExtension(Extension):
"""
A Markdown extension to resolve links to other Markdown files.
"""
def __init__(self, path: str, use_directory_urls: bool):
"""
Initialize the extension.
"""
self.path = path # Current page
self.use_directory_urls = use_directory_urls
def extendMarkdown(self, md: Markdown):
"""
Register Markdown extension.
"""
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 = LinksProcessor(md, self.path, self.use_directory_urls)
md.treeprocessors.register(processor, "relpath", 0)
+201
View File
@@ -0,0 +1,201 @@
# Copyright (c) Zensical LLC <https://zensical.org>
# SPDX-License-Identifier: MIT
# Third-party contributions licensed under CLA
# 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 posixpath
from markdown import Extension, Markdown
from markdown.treeprocessors import Treeprocessor
from urllib.parse import urlparse
from xml.etree.ElementTree import Element
from .links import LinksProcessor
from .utilities.filter import Filter
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class PreviewProcessor(Treeprocessor):
"""
A Markdown treeprocessor to enable instant previews on links.
Note that this treeprocessor is dependent on the `links` treeprocessor
registered programmatically before rendering a page.
"""
def __init__(self, md: Markdown, config: dict):
"""
Initialize the treeprocessor.
"""
super().__init__(md)
self.config = config
def run(self, root: Element):
"""
Run the treeprocessor.
"""
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, LinksProcessor):
raise TypeError("Links 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:
if not configuration.get("sources"):
if not configuration.get("targets"):
continue
# Skip if page should not be considered
filter = get_filter(configuration, "sources")
if not filter(processor.path):
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 headerlinks
if "headerlink" in el.get("class", ""):
continue
# Skip external links
url = urlparse(href)
if url.scheme or url.netloc:
continue
# Include, if filter matches
path = resolve(processor.path, url.path)
if path and filter(path):
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):
"""
Initialize the extension.
"""
self.config = {
"configurations": [[], "Filter configurations"],
"sources": [{}, "Link sources"],
"targets": [{}, "Link targets"],
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md: Markdown):
"""
Register Markdown extension.
"""
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.
"""
return Filter(config=settings.get(key)) # type: ignore
def resolve(processor_path: str, url_path: str) -> str:
"""
Resolve a relative URL path against the processor path.
"""
# Remove the file name from the processor path to get the directory
base_path = posixpath.dirname(processor_path)
# Split the base path and URL path into segments
base_segments = base_path.split("/")
url_segments = url_path.split("/")
# Process each segment in the URL path
for segment in url_segments:
if segment == "..":
# Remove the last segment from the base path if possible
if base_segments:
base_segments.pop()
elif segment and segment != ".":
# Add non-empty, non-current directory segments
base_segments.append(segment)
# Join the base segments into the resolved path
return posixpath.join(*base_segments)
def makeExtension(**kwargs):
"""
Register Markdown extension.
"""
return PreviewExtension(**kwargs)
+373
View File
@@ -0,0 +1,373 @@
# Copyright (c) Zensical LLC <https://zensical.org>
# SPDX-License-Identifier: MIT
# Third-party contributions licensed under CLA
# 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 html import escape
from html.parser import HTMLParser
from markdown import Extension
from markdown.postprocessors import Postprocessor
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class SearchProcessor(Postprocessor):
"""
PostProcessor that extracts searchable content from the rendered HTML.
"""
def __init__(self, md):
super().__init__(md)
self.data = []
def run(self, html):
"""Process the rendered HTML and extract text length."""
# Divide page content into sections
parser = Parser()
parser.feed(html)
parser.close()
# Extract data from sections that are not excluded
self.data = []
for section in parser.data:
if not section.is_excluded():
# Compute title and text
title = "".join(section.title).strip()
text = "".join(section.text).strip()
# Store data for external access
self.data.append(
{
"location": section.id,
"level": section.level,
"title": title,
"text": text,
"path": [],
"tags": [],
}
)
# Return the original HTML unchanged
return html
class SearchExtension(Extension):
"""Markdown extension for search indexing."""
def __init__(self, **kwargs):
self.config = {"keep": [set(), "Set of HTML tags to keep in output"]}
super().__init__(**kwargs)
def extendMarkdown(self, md):
"""Register the PostProcessor with Markdown."""
processor = SearchProcessor(md)
md.postprocessors.register(processor, "search", 0)
def makeExtension(**kwargs):
"""Factory function for creating the extension."""
return SearchExtension(**kwargs)
# -----------------------------------------------------------------------------
# HTML element
class Element:
"""
An element with attributes, essentially a small wrapper object for the
parser to access attributes in other callbacks than handle_starttag.
"""
# Initialize HTML element
def __init__(self, tag, attrs=None):
self.tag = tag
self.attrs = attrs or {}
# String representation
def __repr__(self):
return self.tag
# Support comparison (compare by tag only)
def __eq__(self, other):
if other is Element:
return self.tag == other.tag
else:
return self.tag == other
# Support set operations
def __hash__(self):
return hash(self.tag)
# Check whether the element should be excluded
def is_excluded(self):
return "data-search-exclude" in self.attrs
# -----------------------------------------------------------------------------
# HTML section
class Section:
"""
A block of text with markup, preceded by a title (with markup), i.e., a
headline with a certain level (h1-h6). Internally used by the parser.
"""
# Initialize HTML section
def __init__(self, el, level, depth=0):
self.el = el
self.depth = depth
self.level = level
# Initialize section data
self.text = []
self.title = []
self.id = None
# String representation
def __repr__(self):
if self.id:
return "#".join([self.el.tag, self.id])
else:
return self.el.tag
# Check whether the section should be excluded
def is_excluded(self):
return self.el.is_excluded()
# -----------------------------------------------------------------------------
# HTML parser
class Parser(HTMLParser):
"""
This parser divides the given string of HTML into a list of sections, each
of which are preceded by a h1-h6 level heading. A white- and blacklist of
tags dictates which tags should be preserved as part of the index, and
which should be ignored in their entirety.
"""
# Initialize HTML parser
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Tags to skip
self.skip = set(
[
"object", # Objects
"script", # Scripts
"style", # Styles
]
)
# Current context and section
self.context = []
self.section = None
# All parsed sections
self.data = []
# Called at the start of every HTML tag
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
# Ignore self-closing tags
el = Element(tag, attrs)
if tag not in void:
self.context.append(el)
else:
return
# Handle heading
if tag in ([f"h{x}" for x in range(1, 7)]):
depth = len(self.context)
if "id" in attrs:
# Ensure top-level section
if tag != "h1" and not self.data:
self.section = Section(Element("hx"), 1, depth)
self.data.append(self.section)
# Set identifier, if not first section
self.section = Section(el, int(tag[1:2]), depth)
if self.data:
self.section.id = attrs["id"]
# Append section to list
self.data.append(self.section)
# Handle preface - ensure top-level section
if not self.section:
self.section = Section(Element("hx"), 1)
self.data.append(self.section)
# Handle special cases to skip
for key, value in attrs.items():
# Skip block if explicitly excluded from search
if key == "data-search-exclude":
self.skip.add(el)
return
# Skip line numbers - see https://bit.ly/3GvubZx
if key == "class" and value == "linenodiv":
self.skip.add(el)
return
# Render opening tag if kept
if not self.skip.intersection(self.context) and tag in keep:
# Check whether we're inside the section title
data = self.section.text
if self.section.el in self.context:
data = self.section.title
# Append to section title or text
data.append(f"<{tag}>")
# Called at the end of every HTML tag
def handle_endtag(self, tag):
if not self.context or self.context[-1] != tag:
return
# Check whether we're exiting the current context, which happens when
# a headline is nested in another element. In that case, we close the
# current section, continuing to append data to the previous section,
# which could also be a nested section see https://bit.ly/3IxxIJZ
if self.section.depth > len(self.context):
for section in reversed(self.data):
if section.depth <= len(self.context):
# Set depth to infinity in order to denote that the current
# section is exited and must never be considered again.
self.section.depth = float("inf")
self.section = section
break
# Remove element from skip list
el = self.context.pop()
if el in self.skip:
if el.tag not in ["script", "style", "object"]:
self.skip.remove(el)
return
# Render closing tag if kept
if not self.skip.intersection(self.context) and tag in keep:
# Check whether we're inside the section title
data = self.section.text
if self.section.el in self.context:
data = self.section.title
# Search for corresponding opening tag
index = data.index(f"<{tag}>")
for i in range(index + 1, len(data)):
if not data[i].isspace():
index = len(data)
break
# Remove element if empty (or only whitespace)
if len(data) > index:
while len(data) > index:
data.pop()
# Append to section title or text
else:
data.append(f"</{tag}>")
# Called for the text contents of each tag
def handle_data(self, data):
if self.skip.intersection(self.context):
return
# Collapse whitespace in non-pre contexts
if "pre" not in self.context:
if not data.isspace():
data = data.replace("\n", " ")
else:
data = " "
# Handle preface - ensure top-level section
if not self.section:
self.section = Section(Element("hx"), 1)
self.data.append(self.section)
# Handle section headline
if self.section.el in self.context:
permalink = False
for el in self.context:
if el.tag == "a" and el.attrs.get("class") == "headerlink":
permalink = True
# Ignore permalinks
if not permalink:
self.section.title.append(escape(data, quote=False))
# Collapse adjacent whitespace
elif data.isspace():
if not self.section.text or not self.section.text[-1].isspace():
self.section.text.append(data)
elif "pre" in self.context:
self.section.text.append(data)
# Handle everything else
else:
self.section.text.append(escape(data, quote=False))
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Tags to keep
keep = set(
[
"p",
"code",
"pre",
"li",
"ol",
"ul",
"sub",
"sup",
]
)
# Tags that are self-closing
void = set(
[
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]
)
@@ -0,0 +1,22 @@
# Copyright (c) Zensical LLC <https://zensical.org>
# SPDX-License-Identifier: MIT
# Third-party contributions licensed under CLA
# 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.
@@ -0,0 +1,87 @@
# Copyright (c) Zensical LLC <https://zensical.org>
# SPDX-License-Identifier: MIT
# Third-party contributions licensed under CLA
# 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
from fnmatch import fnmatch
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class Filter:
"""
A filter.
"""
def __init__(self, config: dict):
"""
Initialize the filter.
Arguments:
config: The filter configuration.
"""
self.config = config
def __call__(self, value: str) -> bool:
"""
Filter a value.
First, the inclusion patterns are checked. Regardless of whether they
are present, the exclusion patterns are checked afterwards. This allows
to exclude values that are included by the inclusion patterns, so that
exclusion patterns can be used to refine inclusion patterns.
Arguments:
value: The value to filter.
Returns:
Whether the value should be included.
"""
# Check if value matches one of the inclusion patterns
if "include" in self.config:
for pattern in self.config["include"]:
if fnmatch(value, pattern):
break
# Value is not included
else:
return False
# Check if value matches one of the exclusion patterns
if "exclude" in self.config:
for pattern in self.config["exclude"]:
if fnmatch(value, pattern):
return False
# Value is not excluded
return True
# -------------------------------------------------------------------------
config: dict
"""
The filter configuration.
"""
+172
View File
@@ -0,0 +1,172 @@
# Copyright (c) Zensical LLC <https://zensical.org>
# SPDX-License-Identifier: MIT
# Third-party contributions licensed under CLA
# 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 click
import os
import shutil
import webbrowser
from click import ClickException
from zensical import build, serve
# ----------------------------------------------------------------------------
# Commands
# ----------------------------------------------------------------------------
@click.group()
def cli():
"""Zensical"""
@cli.command(name="build")
@click.option(
"-f",
"--config-file",
type=click.Path(exists=True),
default=None,
help="Path to config file.",
)
@click.option(
"-c",
"--clean",
default=False,
is_flag=True,
help="Clean cache.",
)
@click.option(
"-s",
"--strict",
default=False,
is_flag=True,
help="Strict mode (currently unsupported).",
)
def execute_build(config_file: str | None, **kwargs):
"""
Build a project.
"""
if config_file is None:
for file in ["zensical.toml", "mkdocs.yml", "mkdocs.yaml"]:
if os.path.exists(file):
config_file = file
break
else:
raise ClickException("No config file found in the current folder.")
if kwargs.get("strict", False):
print("Warning: Strict mode is currently unsupported.")
# Build project in Rust runtime, calling back into Python when necessary,
# e.g., to parse MkDocs configuration format or render Markdown
build(os.path.abspath(config_file), kwargs.get("clean"))
@cli.command(name="serve")
@click.option(
"-f",
"--config-file",
type=click.Path(exists=True),
default=None,
help="Path to config file.",
)
@click.option(
"-a",
"--dev-addr",
metavar="<IP:PORT>",
help="IP address and port (default: localhost:8000).",
)
@click.option(
"-o",
"--open",
default=False,
is_flag=True,
help="Open preview in default browser.",
)
@click.option(
"-s",
"--strict",
default=False,
is_flag=True,
help="Strict mode (currently unsupported).",
)
def execute_serve(config_file: str | None, **kwargs):
"""
Build and serve a project.
"""
if config_file is None:
for file in ["zensical.toml", "mkdocs.yml", "mkdocs.yaml"]:
if os.path.exists(file):
config_file = file
break
else:
raise ClickException("No config file found in the current folder.")
# Obtain development server address and open in browser, if desired
dev_addr = kwargs.get("dev_addr") or "localhost:8000"
if kwargs.get("open", False):
webbrowser.open(f"http://{dev_addr}")
if kwargs.get("strict", False):
print("Warning: Strict mode is currently unsupported.")
# Build project in Rust runtime, calling back into Python when necessary,
# e.g., to parse MkDocs configuration format or render Markdown
serve(os.path.abspath(config_file), dev_addr)
@cli.command(name="new")
@click.argument(
"directory",
type=click.Path(file_okay=False, dir_okay=True, writable=True),
required=False,
)
def new_project(directory: str | None, **kwargs):
"""
Create a new template project in the current directory or in the given
directory.
"""
if directory is None:
directory = "."
if os.path.exists(directory):
if not os.path.isdir(directory):
raise (ClickException("Path provided is not a directory."))
if any(os.listdir(directory)):
raise (ClickException("Directory is not empty. Aborting."))
else:
os.makedirs(directory)
# ok, directory exists and is empty
package_dir = os.path.dirname(os.path.abspath(__file__))
shutil.copy(os.path.join(package_dir, "bootstrap/zensical.toml"), directory)
shutil.copytree(
os.path.join(package_dir, "bootstrap/docs"),
os.path.join(directory, "docs"),
)
# ----------------------------------------------------------------------------
# Program
# ----------------------------------------------------------------------------
if __name__ == "__main__": # pragma: no cover
cli()
+139
View File
@@ -0,0 +1,139 @@
# Copyright (c) Zensical LLC <https://zensical.org>
# SPDX-License-Identifier: MIT
# Third-party contributions licensed under CLA
# 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
import yaml
from datetime import date, datetime
from markdown import Markdown
from yaml import SafeLoader
from .config import _CONFIG
from .extensions.links import LinksExtension
from .extensions.search import SearchExtension
# ----------------------------------------------------------------------------
# Constants
# ----------------------------------------------------------------------------
FRONT_MATTER_RE = re.compile(
r"^-{3}[ \t]*\n(.*?\n)(?:\.{3}|-{3})[ \t]*\n", re.UNICODE | re.DOTALL
)
"""
Regex pattern to extract front matter.
"""
# ----------------------------------------------------------------------------
# Functions
# ----------------------------------------------------------------------------
def render(content: str, path: str) -> dict:
"""
Render Markdown and return HTML.
This function returns rendered HTML as well as the table of contents and
metadata. Now, this is the part where Zensical needs to call into Python,
in order to support the specific syntax of Python Markdown. We're working
on moving the entire rendering chain to Rust.
"""
config = _CONFIG
# Initialize Markdown parser
md = Markdown(
extensions=config["markdown_extensions"],
extension_configs=config["mdx_configs"],
)
# Register links extension, which is equivalent to MkDocs' path resolution
# Markdown extension. This is a bandaid, until we move this to Rust
links = LinksExtension(
use_directory_urls=config["use_directory_urls"], path=path
)
links.extendMarkdown(md)
# Register search extension, which extracts text for search indexing
search = SearchExtension()
search.extendMarkdown(md)
# First, extract metadata - the Python Markdown parser brings a metadata
# extension, but the implementation is broken, as it does not support full
# YAML syntax, e.g. lists. Thus, we just parse the metadata with YAML.
meta = {}
if match := FRONT_MATTER_RE.match(content):
try:
meta = yaml.load(match.group(1), SafeLoader)
if isinstance(meta, dict):
content = content[match.end() :].lstrip("\n")
else:
meta = {}
except Exception:
pass
# Convert Markdown and set nullish metadata to empty string, since we
# currently don't have a null value for metadata in the Rust runtime
content = md.convert(content)
for key, value in meta.items():
if value is None:
meta[key] = ""
# Convert datetime back to ISO format (for now)
if isinstance(value, (date, datetime)):
meta[key] = value.isoformat()
# Obtain search index data, unless page is excluded
search = md.postprocessors["search"]
if meta.get("search", {}).get("exclude", False):
search.data = []
# Return Markdown with metadata
return {
"meta": meta,
"content": content,
"search": search.data,
"title": "",
"toc": [_convert_toc(item) for item in getattr(md, "toc_tokens", [])],
}
def _convert_toc(item: any):
"""
Convert a table of contents item to navigation item format.
"""
toc_item = {
"title": item["data-toc-label"] or item["name"],
"id": item["id"],
"url": f"#{item['id']}",
"children": [],
"level": item["level"],
}
# Recursively convert items
for child in item["children"]:
toc_item["children"].append(_convert_toc(child))
# Return table of contents item
return toc_item
+40
View File
@@ -0,0 +1,40 @@
# Copyright (c) Zensical LLC <https://zensical.org>
# SPDX-License-Identifier: MIT
# Third-party contributions licensed under CLA
# 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
# ----------------------------------------------------------------------------
def build(config_file: str, clean: bool):
"""
Builds the project.
"""
def serve(config_file: str, dev_addr: str):
"""
Builds and serves the project.
"""
# ----------------------------------------------------------------------------
__all__ = ["build", "serve"]