1+1=10

记记笔记,放松一下...

Pelican继续了解

重新捡起来 Pelican 写博客已经将近一年,见 Hello Pelican Again。由于开始用到大量公式,重新梳理一下Markdown的一些概念,并使用了katex和mermaid扩展,但是体验并不好,见Markdown小记Python Markdown使用小记。另外,想看看Sass相关以及Jinja2相关内容以便于调整一下模板,也没看懂。

Pelican 是一个基于 Python 的静态网站生成器,广泛用于创建博客、个人网站、文档网站和项目展示页面。它支持 Markdown 和 reStructuredText 格式,允许用户通过模板引擎(如 Jinja2)定制页面布局

Pelican源码结构

既然不准备换掉它,那就走马观花看看,顺便温习python知识点

pyproject.toml

pelican 从多年以前就开始使用 pyproject.toml 文件。

pyproject.toml 是PEP 518 提出的标准配置文件,用于定义 Python 项目的构建工具和元数据。旨在为 Python 项目提供一种统一的、简洁的方式来配置构建工具链。

在它之前,经常可见的文件

  • setup.py
  • setup.cfg
  • requirements.txt (不是完全取代)

文件由各个部分,主要的:

部分 描述
[project] 定义项目的元数据,如名称、版本、描述、作者等。
[project.dependencies] 声明项目的运行时依赖项,类似于 requirements.txt
[project.scripts] 定义可以暴露为命令行的脚本,类似于 entry_points 中的 console_scripts
[build-system] 定义项目的构建工具和构建依赖(如 setuptoolspoetry)。
[tool] 各种工具(如 black, pytest 等)的配置区域。

pyproject.toml 参考链接:

  • https://packaging.python.org/en/latest/specifications/pyproject-toml/#pyproject-toml-spec
  • https://peps.python.org/pep-0518/

对于当前penlican项目,它的scripts部分如下:

1
2
3
4
5
6
[project.scripts]
pelican = "pelican.__main__:main"
pelican-import = "pelican.tools.pelican_import:main"
pelican-plugins = "pelican.plugins._utils:list_plugins"
pelican-quickstart = "pelican.tools.pelican_quickstart:main"
pelican-themes = "pelican.tools.pelican_themes:main"

对应我们在命令行下可用的命令,特别是第一个。对应主命令 pelican。

另外,我们执行如下命令时,调用的也是它:

1
python -m pelican

python -m 的模块搜索路径遵循 sys.path,顺序为当前工作目录、PYTHONPATH 环境变量、标准库路径和第三方库路径。它运行模块和包中的代码,包的入口文件为 __main__.py

main.py 文件

脚本入口文件,真正代码位于别处:

1
2
3
4
from . import main

if __name__ == "__main__":
    main()

__init__py 文件

真正的入口文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Pelican:
    def __init__(self, settings):
        ...

    def init_plugins(self):
        ...

    def run(self):
        ...

def main(argv=None):
    ...
  • main() 是命令行入口,解析命令行并决定执行转换操作还是listen操作。
  • Pelican.run() 函数,函数内生成各个Generator,并按特定顺序执行。

generators.py文件

生成Generator后,创建一个上下文(context)。该上下文包含来自命令行的设置,以及如果提供了设置文件,则也包含其内容。

  • 调用每个生成器的 generate_context() 方法,更新上下文。
  • 创建一个写入器(writer)对象,并将其传递给每个生成器的generate_output() 方法。
  • 注意:Generator的get_template()方法
classDiagram class Generator { +get_template(self, name) } class CachingGenerator { } class TemplatePagesGenerator { +generate_output(writer) } class StaticGenerator { +generate_context() +generate_output(writer) } class SourceFileGenerator { +generate_context() +generate_output(writer) } class ArticleGenerator { +generate_context() +generate_output(writer) +generate_feeds(writer) +generate_pages(writer) } class PagesGenerator { +generate_context() +generate_output(writer) } Generator <|-- CachingGenerator Generator <|-- TemplatePagesGenerator Generator <|-- StaticGenerator Generator <|-- SourceFileGenerator CachingGenerator <|-- ArticleGenerator CachingGenerator <|-- PagesGenerator

以简单的PagesGenerator为例:

generate_context阶段:

  • 读取指定路径,搜索其中特定的文件(比如 .markdown文件等)
  • 使用Reader,将文件加载到特定对象中

generate_output阶段:

  • 使用上面的context 和 Writer,写入生成的内容。
  • 对于ArticleGenerator、PagesGenerator以及TemplatePageGenerator,都会调用Writer的write_file()函数。

jinja2模板加载

模板加载也在这个文件内,通过这个文件,对接jinja2知识

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
from jinja2 import (
    BaseLoader,
    ChoiceLoader,
    Environment,
    FileSystemLoader,
    PrefixLoader,
    TemplateNotFound,
)

class Generator:
     def __init__(self, context, settigns,...):
                self.env = Environment(
            loader=ChoiceLoader(
                [
                    FileSystemLoader(self._templates_path),
                    simple_loader,  # implicit inheritance
                    PrefixLoader(
                        {"!simple": simple_loader, "!theme": theme_loader}
                    ),  # explicit ones
                ]
            ),
            **self.settings["JINJA_ENVIRONMENT"],
        )

                logger.debug("Template list: %s", self.env.list_templates())

        # provide utils.strftime as a jinja filter
        self.env.filters.update({"strftime": DateFormatter()})

        # get custom Jinja filters from user settings
        custom_filters = self.settings["JINJA_FILTERS"]
        self.env.filters.update(custom_filters)

        # get custom Jinja globals from user settings
        custom_globals = self.settings["JINJA_GLOBALS"]
        self.env.globals.update(custom_globals)

        # get custom Jinja tests from user settings
        custom_tests = self.settings["JINJA_TESTS"]
        self.env.tests["plugin_enabled"] = partial(
            plugin_enabled, plugin_list=self.settings["PLUGINS"]
        )
        self.env.tests.update(custom_tests)

    def get_template(self, name):
        """Return the template by name.
        Use self.theme to get the templates to use, and return a list of
        templates ready to use with Jinja2.
        """
        if name not in self._templates:
            for ext in self.settings["TEMPLATE_EXTENSIONS"]:
                try:
                    self._templates[name] = self.env.get_template(name + ext)
                    break
                except TemplateNotFound:
                    continue

            if name not in self._templates:
                raise PelicanTemplateNotFound(
                    "[templates] unable to load {}[{}] from {}".format(
                        name,
                        ", ".join(self.settings["TEMPLATE_EXTENSIONS"]),
                        self._templates_path,
                    )
                )

        return self._templates[name]

readers.py文件

这是用于解析各种源文件的地方。主要关注MarkdownReader。

classDiagram class BaseReader { +process_metadata(name, value) +read(source_path) } class RstReader { +_parse_metadata(document, source_path) dict +read(source_path) :(content, metadata) } class MarkdownReader { -_parse_metadata(meta) dict +read(source_path) :(content, metadata) } class HTMLReader { +read(filename) :(content, metadata) } BaseReader <|-- RstReader BaseReader <|-- MarkdownReader BaseReader <|-- HTMLReader

对MarkdownReader,可以看到基本上没有特殊处理:

  • 处理配置文件中的参数,使用参数直接创建python的Markdown对象
  • 使用Makrdown对象的convert()方法执行转换操作
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class MarkdownReader(BaseReader):
    """Reader for Markdown files"""

    enabled = bool(Markdown)
    file_extensions = ["md", "markdown", "mkd", "mdown"]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        settings = self.settings["MARKDOWN"]
        settings.setdefault("extension_configs", {})
        settings.setdefault("extensions", [])
        for extension in settings["extension_configs"].keys():
            if extension not in settings["extensions"]:
                settings["extensions"].append(extension)
        if "markdown.extensions.meta" not in settings["extensions"]:
            settings["extensions"].append("markdown.extensions.meta")
        self._source_path = None

    def read(self, source_path):
        """Parse content and metadata of markdown files"""

        self._source_path = source_path
        self._md = Markdown(**self.settings["MARKDOWN"])
        with pelican_open(source_path) as text:
            content = self._md.convert(text)

        if hasattr(self._md, "Meta"):
            metadata = self._parse_metadata(self._md.Meta)
        else:
            metadata = {}
        return content, metadata

writers.py文件

Wrriter类主要两个函数:

classDiagram class Writer { +write_feed(elements, context, path=None, url=None, feed_type="atom", ...) +write_file(name, template, context, relative_urls=False, paginated=None, template_name=None, ...) } Writer --> Paginator

主要的函数 write_file()中有一个辅助函数_write_file()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
        def _write_file(template, localcontext, output_path, name, override):
            """Render the template write the file."""
            # set localsiteurl for context so that Contents can adjust links
            if localcontext["localsiteurl"]:
                context["localsiteurl"] = localcontext["localsiteurl"]
            output = template.render(localcontext)
            path = sanitised_join(output_path, name)

            try:
                os.makedirs(os.path.dirname(path))
            except Exception:
                pass

            with self._open_w(path, "utf-8", override=override) as f:
                f.write(output)

从这儿可以看到,模板的 render() 方法被调用。

插件(plugins)

  • 插件接口?
  • 插件的组织结构怎么样?
  • Pelican如何搜索插件?

插件接口

Pelican的插件文件的定义很简单,只需要提供 register()文件作为插件入口!

1
2
3
4
5
6
7
from pelican import signals

def test(sender):
    pass

def register():
    signals.initialized.connect(test)

所有的插件是通过订阅-监听机制来工作的,Pelican中称之为signals。所以register()中主要为需要监听的信号注册响应函数。

插件结构

对于一个名为myplugin插件,其文件组织结构大致如下:

1
2
3
4
5
6
7
├── pelican
│   └── plugins
│       └── myplugin
│           ├── __init__.py
│           └── ...
├── ...
└── pyproject.toml

注意:pelican,pelican/plugins 文件夹下不包含其他文件。

核心文件就一个 __init__.py

用途?

Python写的这东西,尽管简陋,确实灵活

  • https://docs.getpelican.com/en/latest/plugins.html

比如:添加一个新的Reader,以支持新的格式

  • 从 BaseReader 派生,声明支持的文件后缀并实现特定的read()函数
  • readers_init信号进行注册相应,以便于添加新的reader
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from pelican import signals
from pelican.readers import BaseReader

# Create a new reader class, inheriting from the pelican.reader.BaseReader
class NewReader(BaseReader):
    enabled = True  # Yeah, you probably want that :-)

    # The list of file extensions you want this reader to match with.
    # If multiple readers were to use the same extension, the latest will
    # win (so the one you're defining here, most probably).
    file_extensions = ['yeah']

    # You need to have a read method, which takes a filename and returns
    # some content and the associated metadata.
    def read(self, filename):
        metadata = {'title': 'Oh yeah',
                    'category': 'Foo',
                    'date': '2012-12-01'}

        parsed = {}
        for key, value in metadata.items():
            parsed[key] = self.process_metadata(key, value)

        return "Some content", parsed

def add_reader(readers):
    readers.reader_classes['yeah'] = NewReader

# This is how pelican works.
def register():
    signals.readers_init.connect(add_reader)

主题(theme)

这玩意主要是前端的工作了,Jinja2模板,样式表,sass 等

penlican提供一些第三方的主题可用,但一般用户都定制一下。

  • https://docs.getpelican.com/en/latest/themes.html
  • https://github.com/getpelican/pelican-themes

模板结构

  • static部分用于存放主题的静态资源文件,包括 CSS、图片、JavaScript 等。Pelican 会将此目录中的文件复制到输出目录中,与生成的 HTML 文件一起发布。
  • templates目录下的Jinja2模板文件用于定义每个页面的布局和内容显示方式。Pelican 会根据内容类型(文章、标签、分类等)选择相应的模板文件来生成 HTML 页面。下面列出的这些模板必须都存在。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
mytheme/
├── static
│   ├── css
│   ├── js
│   └── images
└── templates
    ├── archives.html         // 显示归档页面
    ├── article.html          // 为每篇文章生成页面
    ├── author.html           // 为每个作者生成页面
    ├── authors.html          // 列出所有作者
    ├── categories.html       // 列出所有分类
    ├── category.html         // 为每个分类生成页面
    ├── index.html            // 网站首页,列出所有文章
    ├── page.html             // 为每个页面生成内容(例如“关于我们”页面)
    ├── period_archives.html  // 按时间段显示归档
    ├── tag.html              // 为每个标签生成页面
    └── tags.html             // 列出所有标签(可以是标签云)

变量

Pelican为模板提供的一些常规变量

变量 描述
output_file 当前正在生成的文件名。例如,当 Pelican 正在渲染首页时,output_file 的值为 "index.html"
articles 文章列表,按日期降序排列。所有元素都是 Article 对象,可访问其属性(如标题、摘要、作者等)。有时会被覆盖(例如在标签页面中)。在这种情况下,可以在 all_articles 变量中找到信息。
dates 相同的文章列表,但按日期升序排列。
hidden_articles 隐藏的文章列表。
drafts 草稿文章列表。
period_archives 一个字典,包含与时间段归档相关的元素(如果启用)。详见“Listing and Linking to Period Archives”部分的细节。
authors (作者, 文章) 元组的列表,包含所有作者及其对应的文章。
categories (分类, 文章) 元组的列表,包含所有分类及其对应的文章。
tags (标签, 文章) 元组的列表,包含所有标签及其对应的文章。
pages 页面列表。
hidden_pages 隐藏的页面列表。
draft_pages 草稿页面列表。

另外,在配置文件 pelicanconf.py 中,可以自由定义变量(注意不要和pelian内置变量冲突),这些自定义变量,在jinja2模板中也可以直接用。

sass

兜兜转转,还是回来折腾折腾这个 octopress 这个久久没人维护的主题。克隆一下进行折腾pelican-octopress-theme

它的样式表是通过sass来生成的,更改样式表要手动修改这些scss文件,而后使用sass工具来生成目标.css。

这个东西变化太多,先勉强从老的ruby版本一直到dart版本。还有一堆警告存在。

首先,安装nodejs及其下的sass包

1
npm install -g sass

而后

1
sass main.scss ../static/css/main.css --style compressed

katex

Pelican的katex支持有两个方案。但都是离线转换方案,在Windows下奇慢无比(数十分钟),即使在github的actions,也是分钟级别的。

实在无法接受,故而当前blog 自己编写Markdown插件,不进行离线转换。速度只需数秒。

markdown-katex

网站:https://github.com/mbarkhau/markdown-katex

markdown-katex 插件通过调用 KaTeX 进行 离线转换,从而生成不需要浏览器加载 JavaScript 的公式。然而:

  • 在我的个人电脑(Windows 10)上,性能表现非常差,尽管在 GitHub Actions 环境中速度尚可。
  • 该插件只支持 GitLab 风格的公式分割符。

pelican-katex

网站:https://github.com/martenlienen/pelican-katex

pelican-katex 也是一个基于 KaTeX 的 离线转换 插件,且封装为 Pelican 插件,适合使用 Pelican 的用户。在实现上,它是一个 Markdown 插件,被 Pelican 通过传递对象的方式调用。

getpelican使用问题

  • 它使用 python-markdown 不支持 commmark,与主流的Markdown编辑器由一定的兼容性问题。
  • 内部链接使用自定义变量{filenme}{static}等,主流的Markdown编辑器不支持。
  • ...

front matter

现在其他软件YAML Front Matter用的比较多,可以使用扩展

  • https://pypi.org/project/pelican-yaml-metadata/

或者

  • https://pypi.org/project/markdown-full-yaml-metadata/
1
2
3
---
title: I am the title of the article
---

注意,pelican源码中默认会启动python-markdown的自己的Mate扩展。

其他编辑器比如vscode等可以识别并隐藏这部分。

nest list

嵌套用4个空格,这是Python Markdown非常坚持的一点。见Issue:https://github.com/Python-Markdown/markdown/issues/3

针对python markdown和其他软件缩进空格不一致问题,可以

  • https://pypi.org/project/mdx-truly-sane-lists/

其他扩展

方案! 手动写Python Markdown扩展,源码使用编辑器常规使用的格式,送入pelican时在内存中自动转换一下。这个终极灵活

比如如果没有可心的katex扩展,那就直接写一下:

  • https://pypi.org/project/md-katex/

commonmark兼容?

另外,pelican也有三方的pelican-markdown-it-reader插件,它使用更符合markdown-it 生态的 markdown-it-py。先观望,后面考虑是否切换。

参考

  • https://docs.getpelican.com/en/latest/internals.html
  • https://github.com/getpelican/pelican
  • https://peps.python.org/pep-0518/
  • https://pypi.org/project/pelican-markdown-it-reader/