Object Model

Site, Page, Section, and Asset data models

7 min read 1409 words

Bengal's object model provides a rich, hierarchical representation of site content with clear relationships and responsibilities.

Core Objects

Central Data Container (bengal/core/site.py)

Holds all site content and delegates build coordination. It is a passive data container, not a "God object".

Key Attributes:

  • pages: List of all Page objects
  • sections: List of all Section objects
  • assets: List of all Asset objects
  • config: Configuration dictionary
  • menu: Built navigation menus

Key Methods:

  • build(): Delegates toBuildOrchestrator
  • discover_content(): Delegates toContentOrchestrator

Content Unit (bengal/core/page/)

Represents a single content page with source, metadata, rendered HTML, and navigation.

Architecture:

  • Composition Pattern:Pagecontains aPageCoreinstance for cacheable metadata
  • Split into focused mixins:
    • page_core.py: Cacheable metadata (title, date, tags, etc.)
    • metadata.py: Frontmatter parsing
    • navigation.py: Next/prev/parent links
    • relationships.py: Section membership
    • computed.py: URL generation, TOC
    • operations.py: Rendering logic

PageCore Integration:

  • Cacheable fields (title, date, tags, slug) stored inpage.core
  • Property delegates provide direct access:page.titlepage.core.title
  • Enables type-safe caching and lazy loading viaPageProxy

Structural Unit (bengal/core/section.py)

Represents folder-based grouping of pages with hierarchical organization.

Features:

  • Hierarchy: Parent/child relationships (subsections)
  • Navigation: Access toregular_pagesandsections
  • Cascade: Inheritance of frontmatter metadata to descendants
  • Path-based Registry: O(1) lookup viaSite._section_registryusing normalized paths
  • Stable References: Sections referenced by path strings (not object identity) for reliable incremental builds

Static Resource (bengal/core/asset/)

Handles static files (images, CSS, JS) with optimization.

Capabilities:

  • Minification (CSS/JS)
  • Image optimization
  • Cache busting (fingerprinting)
  • Output copying

Navigation Structure (bengal/core/menu.py)

Provides hierarchical navigation menus built from config + frontmatter.

Components:

  • MenuItem: Nested item with active state
  • MenuBuilder: Constructs hierarchy and marks active items

PageCore Architecture (Implemented in 0.1.4)

Bengal uses the PageCore composition pattern to enforce cache-proxy contract safety and enable fast incremental builds.

The Problem It Solves

To enable incremental builds, we cache page metadata and lazy-load full content only when needed. Previously, this required manually keeping three representations in sync:

  • Page(live object with full content)
  • PageMetadata(cached metadata for navigation)
  • PageProxy(lazy-loading wrapper)

Risk: Forgetting to update one representation caused cache bugs.

The Solution: PageCore

PageCoreis the single source of truth for all cacheable page metadata. Any field added toPageCoreautomatically becomes available in all three representations.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@dataclass
class PageCore(Cacheable):
    """Cacheable page metadata shared between Page, PageMetadata, and PageProxy."""
    source_path: str  # String, not Path (JSON compatibility)
    title: str
    date: datetime | None = None
    tags: list[str] = field(default_factory=list)
    slug: str | None = None
    weight: int | None = None
    type: str | None = None
    section: str | None = None  # Path as string (stable reference)
    file_hash: str | None = None

Architecture

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Composition: Page contains PageCore
class Page:
    core: PageCore  # Cacheable metadata
    content: str    # Non-cacheable (requires parsing)
    rendered_html: str  # Build artifact

    @property
    def title(self) -> str:
        return self.core.title  # Property delegate

# Type alias: PageMetadata IS PageCore
PageMetadata = PageCore

# Wrapper: PageProxy wraps PageCore
class PageProxy:
    _core: PageCore  # Direct access, no lazy load needed

    @property
    def title(self) -> str:
        return self._core.title  # Direct from core

Benefits

  1. Type Safety: Compiler enforces all three representations stay in sync
  2. Simplified Caching:asdict(page.core)serializes all cacheable fields
  3. Performance: Core fields accessible without lazy loading
  4. Maintainability: Adding new field requires only 3 changes (PageCore + 2 property delegates)

Adding New Cacheable Fields

When adding a new cacheable field, update three locations:

  1. Add to PageCore (bengal/core/page/page_core.py):
1
2
3
4
@dataclass
class PageCore(Cacheable):
    # ... existing fields ...
    author: str | None = None  # NEW
  1. Add property delegate to Page (bengal/core/page/__init__.py):
1
2
3
@property
def author(self) -> str | None:
    return self.core.author
  1. Add property delegate to PageProxy (bengal/core/page/proxy.py):
1
2
3
@property
def author(self) -> str | None:
    return self._core.author

That's it! The field is now available in Page, PageMetadata, and PageProxy. The compiler will catch any missing implementations.

What Goes in PageCore?

✅ DO Include If:

  • Field comes from frontmatter (title, date, tags, slug, etc.)
  • Field is computed without full content parsing (URL path components)
  • Field needs to be accessible in templates without lazy loading
  • Field is cascaded from section_index.md(type, layout, etc.)
  • Field is used for navigation (section reference as path)

❌ DO NOT Include If:

  • Field requires full content parsing (toc, excerpt, meta_description)
  • Field is a build artifact (output_path, links, rendered_html)
  • Field changes every build (timestamp, render_time)
  • Field is computed from other non-cacheable fields

See:bengal/core/page/page_core.pyfor implementation details.

Stable Section References

Bengal uses path-based section references instead of object identity for reliable incremental builds.

Path-Based Registry

Sections are stored in a dictionary keyed by normalized paths:

1
2
3
4
5
6
class Site:
    _section_registry: dict[Path, Section]  # O(1) lookup

    def get_section_by_path(self, path: Path | str) -> Section | None:
        normalized = self._normalize_section_path(path)
        return self._section_registry.get(normalized)  # O(1) lookup

Benefits

  • Stable Across Rebuilds: Path strings persist in cache, not object references
  • O(1) Lookup: Dictionary lookup is constant time
  • Reliable Incremental Builds: Sections can be renamed/moved without breaking references
  • Feature Flag:stable_section_referencesconfig flag enables path-based tracking

Implementation

  • Sections stored as path strings inPageCore.section(not Section objects)
  • Registry built duringSite.register_sections()
  • Dev server forces full rebuild on file create/delete/move to preserve relationships
  • Performance regression tests validate no slowdown (tests/integration/test_full_build_performance.py)

See:bengal/core/site.pyfor implementation details.

Object Model Relationships

classDiagram Site "1" --> "*" Page : manages Site "1" --> "*" Section : contains Site "1" --> "*" Asset : tracks Site "1" --> "*" MenuBuilder : uses MenuBuilder "1" --> "*" MenuItem : builds Section "1" --> "*" Page : groups Section "1" o-- "0..1" Page : index_page Section "1" --> "*" Section : subsections Section --> Section : parent Page --> Page : next/prev Page --> Page : next_in_section/prev_in_section Page --> Section : parent MenuItem --> MenuItem : children (nested) class Site { +root_path: Path +config: Dict +pages: List~Page~ +sections: List~Section~ +build() } class Page { +core: PageCore +content: str +rendered_html: str +render() } class PageCore { +source_path: str +title: str +date: datetime +tags: list +section: str } Page "1" *-- "1" PageCore : contains PageProxy "1" *-- "1" PageCore : wraps class Section { +name: str +path: Path +pages: List~Page~ +subsections: List~Section~ }

Object Tree Access in Directives

As of v0.1.5, the object tree is directly accessible to MyST directives during markdown parsing. This enables powerful navigation directives like{child-cards},{breadcrumbs},{siblings}, and{prev-next}.

How It Works

During markdown rendering, theMistuneParsersetsrenderer._current_pageto the page being rendered:

1
2
# In MistuneParser.parse_with_context
self._shared_renderer._current_page = current_page

Directives can then access the full object tree:

1
2
3
4
5
6
# In a directive
current_page = getattr(renderer, "_current_page", None)
if current_page:
    section = current_page._section  # Parent section
    subsections = section.subsections  # Child sections
    pages = section.pages  # Sibling pages

Performance Characteristics

Access Pattern Complexity Notes
page._section O(1) Direct reference
section.subsections O(1) Pre-computed list
section.pages O(1) Pre-computed list
page.ancestors O(depth) Walks up tree
page.related_posts O(n) Tag matching

Available on Page Object

Property Type Description
_section Section Parent section
metadata dict Frontmatter values
title str Page title
url str Page URL
ancestors list Parent sections to root
prev_in_section Page Previous page
next_in_section Page Next page
related_posts list Pages with matching tags

Available on Section Object

Property Type Description
name str Section name
index_page Page Section's_index.md
pages list Direct child pages
subsections list Child sections
sorted_pages list Pages sorted by weight/date

Writer Usage

Writers use navigation directives in markdown without knowing the implementation:

1
2
3
4
:::{child-cards}
:columns: 2
:include: sections
:::

The directive walkspage._section.subsectionsto generate cards automatically.

See Navigation Directives for full reference.