Skip to content

API Reference

The public API of zensical_updates, generated from the source docstrings. Only names exported in __all__ are shown.

zensical_updates

Generate a dated Updates (blog) section for zensical sites as plain Markdown.

The public API is re-exported here and listed in __all__ — listing names in __all__ is what marks them as the supported, explicitly re-exported surface for type checkers (mypy strict / basedpyright) and the autodoc tooling.

BuildResult dataclass

What a build produced: the output dir, files written, and page URLs.

page_urls is the site-absolute URL of every generated content page (the index, each post, and the archive and taxonomy pages), recorded as it is written so the sitemap lists exactly those. The feed and sitemap files land in written but not here. post_urls is the post subset.

Source code in src/zensical_updates/build.py
@dataclass
class BuildResult:
    """What a build produced: the output dir, files written, and page URLs.

    ``page_urls`` is the site-absolute URL of every generated content page (the
    index, each post, and the archive and taxonomy pages), recorded as it is
    written so the sitemap lists exactly those. The feed and sitemap files land in
    ``written`` but not here. ``post_urls`` is the post subset.
    """

    out_dir: Path
    written: list[Path] = field(default_factory=list)
    post_urls: list[str] = field(default_factory=list)
    page_urls: list[str] = field(default_factory=list)
    feed_path: Path | None = None
    sitemap_path: Path | None = None

Config dataclass

Resolved generator settings.

Source code in src/zensical_updates/config.py
@dataclass(frozen=True)
class Config:
    """Resolved generator settings."""

    base: str = "updates"
    source: str = "updates"
    intro: str = "index.md"
    excerpt_marker: str = "<!-- more -->"
    emit_tags: bool = True
    emit_categories: bool = True
    emit_archive: bool = True
    site_url: str = ""
    site_name: str = ""
    site_description: str = ""
    language: str = "en"
    emit_feed: bool = True
    feed_limit: int = 0
    emit_sitemap: bool = True

    @property
    def url_base(self) -> str:
        """The URL base for emitted links: the site sub-path joined to ``base``.

        A project Pages site is served under ``site_url``'s path (e.g.
        ``/eth-protocol-fellowship/``); a link that omits it 404s. A root-served
        site has no path, so the base is just ``base`` with no extra slash. The
        on-disk output dir is built from ``base`` alone, so it is unaffected.
        """
        prefix = urlparse(self.site_url).path.rstrip("/")
        return f"{prefix}/{self.base}".strip("/")

url_base property

url_base: str

The URL base for emitted links: the site sub-path joined to base.

A project Pages site is served under site_url's path (e.g. /eth-protocol-fellowship/); a link that omits it 404s. A root-served site has no path, so the base is just base with no extra slash. The on-disk output dir is built from base alone, so it is unaffected.

FeedError

Bases: RuntimeError

The feed could not be generated (e.g. the zensical render API moved).

Source code in src/zensical_updates/feed.py
class FeedError(RuntimeError):
    """The feed could not be generated (e.g. the zensical render API moved)."""

Post dataclass

One update post, parsed from a source Markdown file.

Source code in src/zensical_updates/model.py
@dataclass(frozen=True)
class Post:
    """One update post, parsed from a source Markdown file."""

    slug: str
    title: str
    date: datetime.date
    categories: tuple[str, ...]
    tags: tuple[str, ...]
    body: str
    excerpt: str
    source_path: Path

PostError

Bases: ValueError

A post file is missing required front matter or carries an invalid value.

Source code in src/zensical_updates/model.py
class PostError(ValueError):
    """A post file is missing required front matter or carries an invalid value."""

build_site

build_site(config: Config, root: Path) -> BuildResult

Generate the full section into docs/<base>/ and report what was written.

Source code in src/zensical_updates/build.py
def build_site(config: Config, root: Path) -> BuildResult:
    """Generate the full section into ``docs/<base>/`` and report what was written."""
    source_dir = root / config.source
    out_dir = root / "docs" / config.base
    clean_site(config, root)
    out_dir.mkdir(parents=True, exist_ok=True)

    posts = discover_posts(source_dir, index_name=config.intro)
    # Links carry the site sub-path (config.url_base); the output dir above uses
    # config.base, so a project Pages deploy is served correctly without moving files.
    base = config.url_base
    result = BuildResult(out_dir=out_dir)

    # Copy each post verbatim so it renders at /<base>/<slug>/.
    for post in posts:
        dest = out_dir / f"{post.slug}.md"
        shutil.copyfile(post.source_path, dest)
        url = post_url(base, post.slug)
        result.written.append(dest)
        result.post_urls.append(url)
        result.page_urls.append(url)

    intro = _read_intro(source_dir / config.intro)
    _write(
        out_dir / "index.md",
        render_index(
            posts,
            base,
            intro=intro,
            emit_tags=config.emit_tags,
            emit_categories=config.emit_categories,
            emit_archive=config.emit_archive,
        ),
        index_url(base),
        result,
    )

    if config.emit_archive:
        _write(
            out_dir / "archive" / "index.md",
            render_archive_index(
                posts, base, emit_tags=config.emit_tags, emit_categories=config.emit_categories
            ),
            archive_url(base),
            result,
        )
        for year, year_posts in group_by_year(posts).items():
            page = render_year(year, year_posts, base)
            _write(out_dir / "archive" / str(year) / "index.md", page, year_url(base, year), result)

    if config.emit_tags:
        tags = group_by_tag(posts)
        _write(
            out_dir / "tags" / "index.md",
            render_tag_index(
                tags, base, emit_categories=config.emit_categories, emit_archive=config.emit_archive
            ),
            tag_index_url(base),
            result,
        )
        for tag, tag_posts in tags.items():
            page = render_tag(tag, tag_posts, base)
            _write(out_dir / "tags" / slugify(tag) / "index.md", page, tag_url(base, tag), result)

    if config.emit_categories:
        cats = group_by_category(posts)
        _write(
            out_dir / "categories" / "index.md",
            render_category_index(
                cats, base, emit_tags=config.emit_tags, emit_archive=config.emit_archive
            ),
            category_index_url(base),
            result,
        )
        for cat, cat_posts in cats.items():
            page = render_category(cat, cat_posts, base)
            _write(
                out_dir / "categories" / slugify(cat) / "index.md",
                page,
                category_url(base, cat),
                result,
            )

    if config.emit_feed and config.site_url:
        render = make_renderer(root, config)
        feed_posts = posts if config.feed_limit <= 0 else posts[: config.feed_limit]
        feed_xml = build_feed(config, feed_posts, render)
        feed_file = out_dir / "feed.xml"
        feed_file.write_text(feed_xml, encoding="utf-8")
        result.written.append(feed_file)
        result.feed_path = feed_file

    # A sitemap needs absolute locations, so it shares the feed's site_url gate.
    if config.emit_sitemap and config.site_url:
        sitemap_xml = build_sitemap(config, result.page_urls)
        sitemap_file = out_dir / "sitemap.xml"
        sitemap_file.write_text(sitemap_xml, encoding="utf-8")
        result.written.append(sitemap_file)
        result.sitemap_path = sitemap_file

    return result

clean_site

clean_site(config: Config, root: Path) -> None

Remove the generated output tree, if present.

Source code in src/zensical_updates/build.py
def clean_site(config: Config, root: Path) -> None:
    """Remove the generated output tree, if present."""
    out_dir = root / "docs" / config.base
    if out_dir.exists():
        shutil.rmtree(out_dir)

load_config

load_config(path: Path) -> Config

Read the config table from zensical.toml, defaulting any absent key.

Source code in src/zensical_updates/config.py
def load_config(path: Path) -> Config:
    """Read the config table from ``zensical.toml``, defaulting any absent key."""
    project = _project(path)
    table = _table(project)
    d = Config()
    return Config(
        base=str(table.get("base", d.base)),
        source=str(table.get("source", d.source)),
        intro=str(table.get("intro", d.intro)),
        excerpt_marker=str(table.get("excerpt_marker", d.excerpt_marker)),
        emit_tags=bool(table.get("emit_tags", d.emit_tags)),
        emit_categories=bool(table.get("emit_categories", d.emit_categories)),
        emit_archive=bool(table.get("emit_archive", d.emit_archive)),
        site_url=str(project.get("site_url", d.site_url)),
        site_name=str(project.get("site_name", d.site_name)),
        site_description=str(project.get("site_description", d.site_description)),
        language=str(project.get("language", d.language)),
        emit_feed=bool(table.get("emit_feed", d.emit_feed)),
        feed_limit=int(table.get("feed_limit", d.feed_limit)),
        emit_sitemap=bool(table.get("emit_sitemap", d.emit_sitemap)),
    )

build_feed

build_feed(
    config: Config,
    posts: Sequence[Post],
    render: Callable[[Post], str],
) -> str

Assemble the RSS 2.0 feed XML from posts, newest first.

render turns a post into absolutized HTML; it is injected so tests can stub it without driving zensical. posts is already capped and ordered by the caller.

Source code in src/zensical_updates/feed.py
def build_feed(config: Config, posts: Sequence[Post], render: Callable[[Post], str]) -> str:
    """Assemble the RSS 2.0 feed XML from ``posts``, newest first.

    ``render`` turns a post into absolutized HTML; it is injected so tests can
    stub it without driving zensical. ``posts`` is already capped and ordered by
    the caller.
    """
    from feedgen.feed import FeedGenerator  # noqa: PLC0415

    generator = FeedGenerator()
    index_url = absolute_url(config.site_url, f"/{config.url_base}/")
    feed_url = absolute_url(config.site_url, f"/{config.url_base}/feed.xml")
    generator.title(config.site_name or config.base)
    # feedgen renders the RSS channel <link> from the last link() call, so the
    # self link goes first and the index (alternate) last, leaving <link> on the
    # index. The self link still emits its <atom:link rel="self">.
    generator.link(href=feed_url, rel="self")
    generator.link(href=index_url, rel="alternate")
    generator.description(config.site_description or config.site_name or config.base)
    generator.language(config.language or "en")
    generator.generator("zensical-updates")
    if posts:
        generator.lastBuildDate(_to_datetime(posts[0].date))

    for post in reversed(list(posts)):
        post_abs = absolute_url(config.site_url, post_url(config.url_base, post.slug))
        entry = generator.add_entry()
        entry.guid(post_abs, permalink=True)
        entry.title(post.title)
        entry.link(href=post_abs)
        entry.pubDate(_to_datetime(post.date))
        # The short excerpt goes in <description>; the full rendered HTML goes in
        # <content:encoded>. feedgen emits both elements when description and content
        # are set, and declares xmlns:content on the channel. lxml splits any literal
        # "]]>" in the content so the CDATA stays well-formed. Without a non-blank
        # excerpt there is no summary, so feedgen carries the full HTML in
        # <description> instead.
        if post.excerpt and post.excerpt.strip():
            entry.description(post.excerpt.strip())
        entry.content(render(post), type="CDATA")

    return str(generator.rss_str(pretty=True).decode("utf-8"))

discover_posts

discover_posts(
    source: Path, *, index_name: str = "index.md"
) -> list[Post]

Find and parse every post in source, newest first.

The intro file (index_name) is the section landing copy, not a post, so it is skipped. Ordering is date descending, ties broken by slug ascending, so the output is deterministic.

Source code in src/zensical_updates/model.py
def discover_posts(source: Path, *, index_name: str = "index.md") -> list[Post]:
    """Find and parse every post in ``source``, newest first.

    The intro file (``index_name``) is the section landing copy, not a post, so
    it is skipped. Ordering is date descending, ties broken by slug ascending,
    so the output is deterministic.
    """
    posts = [load_post(path) for path in sorted(source.glob("*.md")) if path.name != index_name]
    posts.sort(key=lambda p: p.slug)
    posts.sort(key=lambda p: p.date, reverse=True)
    return posts

load_post

load_post(path: Path) -> Post

Parse a single post file into a :class:Post.

Source code in src/zensical_updates/model.py
def load_post(path: Path) -> Post:
    """Parse a single post file into a :class:`Post`."""
    meta, body = split_front_matter(path.read_text(encoding="utf-8"))
    raw_date = meta.get("date")
    if raw_date is None:
        msg = f"{path}: post front matter is missing a 'date'"
        raise PostError(msg)
    title = meta.get("title") or path.stem
    return Post(
        slug=path.stem,
        title=str(title),
        date=_coerce_date(raw_date, path),
        categories=_str_tuple(meta.get("categories")),
        tags=_str_tuple(meta.get("tags")),
        body=body,
        excerpt=make_excerpt(body),
        source_path=path,
    )

build_sitemap

build_sitemap(
    config: Config, page_urls: Sequence[str]
) -> str

Render the sitemap XML for the pages the build recorded in page_urls.

The locations are absolute, so it needs config.site_url; the build calls it only when that is set.

Source code in src/zensical_updates/sitemap.py
def build_sitemap(config: Config, page_urls: Sequence[str]) -> str:
    """Render the sitemap XML for the pages the build recorded in ``page_urls``.

    The locations are absolute, so it needs ``config.site_url``; the build calls it
    only when that is set.
    """
    return render_sitemap(config.site_url, page_urls)