mirror of
https://github.com/zensical/zensical.git
synced 2026-05-06 02:50:34 +00:00
workspace:chore - initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
templates
|
||||
@@ -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__
|
||||
@@ -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
|
||||
@@ -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">
|
||||
{ width="300" }
|
||||
<figcaption>Image caption</figcaption>
|
||||
</figure>
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user