feature: add social MkDocs plugin replacement

* feature: add `social` MkDocs plugin replacement

Signed-off-by: squidfunk <martin.donath@squidfunk.com>

* fix: align social card rendering with Material

Signed-off-by: squidfunk <martin.donath@squidfunk.com>

* chore: document social internals and clear lint gate

Signed-off-by: squidfunk <martin.donath@squidfunk.com>

* fix: align social typography and expand parity coverage

Signed-off-by: squidfunk <martin.donath@squidfunk.com>

* test: avoid case-colliding exclude fixture paths

Signed-off-by: squidfunk <martin.donath@squidfunk.com>

* fix: define ARM architecture for Linux cross builds

Signed-off-by: squidfunk <martin.donath@squidfunk.com>

---------

Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
Martin Donath authored and GitHub committed 2026-09-29 16:45:31 +02:00
1 parent 4c2b8e2a07
commit f7596cf94a
122 files changed
+6722 -63

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
# Social plugin compatibility matrix
These projects compare the user-visible output of Material for MkDocs and
Zensical with the same `mkdocs.yml` and source files. Run the matrix from the
repository root:
```console
/Users/squidfunk/.workspace/squidfunk/repos/mkdocs-material/community/venv/bin/python \
scripts/social_compatibility.py \
--mkdocs /Users/squidfunk/.workspace/squidfunk/repos/mkdocs-material/community/venv/bin/mkdocs \
--zensical .venv/bin/zensical
```
| Case | Behavior |
| --- | --- |
| `basic` | Custom layout, root and nested card routes, page title and metadata |
| `filters` | Include precedence, exclude patterns, page-level opt-out |
| `metadata` | Meta plugin inheritance and page-level layout options |
| `multiple` | Ordered plugin instances, separate card directories and metadata |
| `no-site-url` | Cards generated without public image metadata |
| `image-only` | Bundled image-only layout and a local SVG dependency |
| `bundled` | Default, accent, invert and variant layouts with typography |
| `logo-icon` | Explicit theme logo icon and omitted font settings |
| `debug` | Build-time debug grid and color settings |
| `blog` | Cards for generated blog views and posts |
| `disabled` | Disabled plugin stays disabled despite a page opt-in |
| `cards-off` | Global card switch with a page-level opt-in |
| `paths` | Flat URLs, alternate card directory and custom layout directory |
| `layout-options` | Bundled-layout image, logo, title, description and page overrides |
| `layer-composition` | YAML definitions, SVG background, icon, origins and offsets |
| `theme-defaults` | Palette list, SVG/PNG logos, exact font face and variant fallback |
| `custom-typography` | Custom alignment, wrapping, shrinking and line spacing |
| `template-context` | Page file and URL values inside custom tag templates |
| `lifecycle` | Cached rebuilds after image, page metadata and layout edits |
| `recoverable-error` | Missing image with ignored card-generation error |
| `deprecated` | Deprecated `cards_color` and `cards_font` settings |
| `debug-no-grid` | Debug overlay without a grid |
The check compares generated card paths, dimensions and every card's mean RGB
pixel difference, plus each page's social metadata. A case passes when the
metadata and dimensions match and every card's mean RGB difference is at most
1 on a 0–255 scale. The `lifecycle` case also rebuilds both engines after each
exact source edit and checks that the expected output changed. PNG file bytes
may differ between image libraries even when the visible output is the same.
Build logs and full output are kept in a temporary directory only while the
command runs; pass `--output PATH` to keep them for inspection.
The cases using typography or debug labels download Roboto from Google Fonts
on their first build. Use `--font-cache PATH` to seed both temporary projects
from an existing Material font cache and run without a network connection.
On 2026-09-23, 19 of 22 cases passed the strict pixel threshold using Material
for MkDocs 9.7.1 with Pillow 12.1.1 and this Zensical branch. One remaining
case is an accepted rasterization difference; the other two are behavior gaps:
- `custom-typography`: text placement matches visually. Different text
rasterizers exceed the strict pixel threshold (maximum mean RGB difference
3.636), but this is not considered a user-visible defect.
- `template-context`: Material exposes `page.file.src_uri` to layout templates;
the Zensical build fails because that value is undefined.
- `lifecycle`: after a cached SVG background edit, Material retains its old
card while Zensical updates it. The initial build and later layout edit match.
The full matrix currently exits nonzero because of the strict pixel threshold
and the two behavior gaps.
It does not prove every custom layout, live `serve` edit, remote font, or error
policy combination. Configuration keys are represented across this matrix and
the Python integration tests, but a finite set of cases cannot prove universal
behavioral parity.
@@ -0,0 +1 @@
# Advanced
@@ -0,0 +1 @@
# Guide
+6
View File
@@ -0,0 +1,6 @@
---
title: Home & Intro
description: A concise & useful description.
---
# Welcome
+12
View File
@@ -0,0 +1,12 @@
tags:
og:type: website
og:title: '{{ page.meta.get("title", page.title) }}'
og:description: '{{ page.meta.get("description", config.site_description) | x }}'
og:image: '{{ image.url }}'
og:image:width: '{{ image.width }}'
og:image:height: '{{ image.height }}'
og:url: '{{ page.canonical_url }}'
twitter:card: summary_large_image
size: { width: 320, height: 168 }
layers:
- background: { color: '#123456' }
+10
View File
@@ -0,0 +1,10 @@
site_name: Social parity
site_description: A small parity site.
site_url: https://example.test/docs/
site_dir: site
theme:
name: material
plugins:
- social:
cache: false
cards_layout: flat
+1
View File
@@ -0,0 +1 @@
# Journal
@@ -0,0 +1,5 @@
---
date: 2026-09-20
---
# First post
@@ -0,0 +1,5 @@
---
date: 2026-09-21
---
# Second post
+1
View File
@@ -0,0 +1 @@
# Home
+6
View File
@@ -0,0 +1,6 @@
tags:
og:title: '{{ page.meta.get("title", page.title) }}'
og:image: '{{ image.url }}'
size: { width: 320, height: 168 }
layers:
- background: { color: '#123456' }
+9
View File
@@ -0,0 +1,9 @@
site_name: Social blog
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- blog
- social:
cache: false
cards_layout: flat
+7
View File
@@ -0,0 +1,7 @@
---
title: An accent card
social:
cards_layout: default/accent
---
# Accent
+6
View File
@@ -0,0 +1,6 @@
---
title: A thoughtful beginning
description: A concise introduction to the project.
---
# Home
+7
View File
@@ -0,0 +1,7 @@
---
title: An inverted card
social:
cards_layout: default/invert
---
# Invert
+7
View File
@@ -0,0 +1,7 @@
---
title: A variant card
social:
cards_layout: default/variant
---
# Variant
+19
View File
@@ -0,0 +1,19 @@
site_name: Bundled cards
site_description: A site description for cards.
site_url: https://example.test/docs/
site_dir: site
theme:
name: material
palette:
primary: deep purple
font:
text: Roboto
plugins:
- social:
cache: false
cache_dir: social-cache
cards_layout: default
cards_layout_options:
background_color: '#4c1d95'
color: '#ffffff'
font_family: Roboto
+3
View File
@@ -0,0 +1,3 @@
# Home
Global card generation is disabled for this page.
+8
View File
@@ -0,0 +1,8 @@
---
social:
cards: true
---
# Page opt-in
This page explicitly enables a card.
@@ -0,0 +1,6 @@
tags:
og:image: '{{ image.url }}'
x:mode: opt-in
size: { width: 320, height: 168 }
layers:
- background: { color: '#234567' }
+9
View File
@@ -0,0 +1,9 @@
site_name: Global cards off
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- social:
cache: false
cards: false
cards_layout: flat
@@ -0,0 +1,6 @@
---
title: Custom typography
description: A short description to inspect bottom alignment.
---
# Home
@@ -0,0 +1,6 @@
---
title: A very long headline that should wrap over several lines before the type becomes too small
description: A longer description which should fit into two lines and reveal whether end alignment and line spacing match.
---
# Long typography
@@ -0,0 +1,24 @@
tags:
og:image: '{{ image.url }}'
x:title: '{{ page.title }}'
size: { width: 600, height: 315 }
layers:
- background: { color: '#14263d' }
- size: { width: 500, height: 110 }
offset: { x: 50, y: 40 }
typography:
content: '{{ page.meta.get("title", page.title) }}'
align: center
overflow: shrink
color: '#ffffff'
line: { amount: 2, height: 1.25 }
font: { family: Roboto, style: Bold }
- size: { width: 500, height: 65 }
offset: { x: 50, y: 220 }
typography:
content: '{{ page.meta.get("description", "") }}'
align: end bottom
overflow: truncate
color: '#bdd4eb'
line: { amount: 2, height: 1.1 }
font: { family: Roboto, style: Regular }
@@ -0,0 +1,9 @@
site_name: Custom typography
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- social:
cache: false
cache_dir: social-cache
cards_layout: type
@@ -0,0 +1 @@
# Home
@@ -0,0 +1,5 @@
tags:
og:image: '{{ image.url }}'
size: { width: 320, height: 168 }
layers:
- background: { color: '#324a62' }
+13
View File
@@ -0,0 +1,13 @@
site_name: Debug without grid
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- social:
cache: false
cache_dir: social-cache
cards_layout: flat
debug: true
debug_on_build: true
debug_grid: false
debug_color: blue
+1
View File
@@ -0,0 +1 @@
# Debug view
+5
View File
@@ -0,0 +1,5 @@
tags:
og:image: '{{ image.url }}'
size: { width: 320, height: 168 }
layers:
- background: { color: '#123456' }
+14
View File
@@ -0,0 +1,14 @@
site_name: Debug cards
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- social:
cache: false
cache_dir: social-cache
cards_layout: flat
debug: true
debug_on_build: true
debug_grid: true
debug_grid_step: 32
debug_color: red
+1
View File
@@ -0,0 +1 @@
# Home
@@ -0,0 +1,5 @@
tags:
og:image: '{{ image.url }}'
size: { width: 320, height: 168 }
layers:
- background: { color: '#102030' }
+10
View File
@@ -0,0 +1,10 @@
site_name: Deprecated social options
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- social:
cache: false
cards_layout: flat
cards_color: { fill: '#102030', text: '#ffffff' }
cards_font: Roboto
+3
View File
@@ -0,0 +1,3 @@
# Home
The disabled plugin must not generate a card or social metadata.
+8
View File
@@ -0,0 +1,8 @@
---
social:
cards: true
---
# Page opt-in
A page override must not re-enable a disabled plugin.
+7
View File
@@ -0,0 +1,7 @@
site_name: Disabled social plugin
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- social:
enabled: false
@@ -0,0 +1 @@
# Include wins over exclude
@@ -0,0 +1 @@
# Included guide
@@ -0,0 +1,6 @@
---
social:
cards: false
---
# Page opt-out
+1
View File
@@ -0,0 +1 @@
# Home
+5
View File
@@ -0,0 +1,5 @@
tags:
og:image: '{{ image.url }}'
size: { width: 320, height: 168 }
layers:
- background: { color: '#abcdef' }
+10
View File
@@ -0,0 +1,10 @@
site_name: Social filters
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- social:
cache: false
cards_layout: flat
cards_include: ['guides/**']
cards_exclude: ['guides/hidden.md']
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">
<rect width="1200" height="630" fill="#345678"/>
</svg>

After

Width:  |  Height:  |  Size: 125 B

+1
View File
@@ -0,0 +1 @@
# Image card
+10
View File
@@ -0,0 +1,10 @@
site_name: Social image only
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- social:
cache: false
cards_layout: default/only/image
cards_layout_options:
background_image: docs/background.svg
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
<rect width="400" height="200" fill="#1b334d"/>
<circle cx="200" cy="100" r="65" fill="#527da8"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1 @@
# Home
@@ -0,0 +1,15 @@
definitions:
- &accent '#eeaa66'
tags:
og:image: '{{ image.url }}'
x:layer: composed
size: { width: 400, height: 200 }
layers:
- background: { image: docs/background.svg }
- size: { width: 100, height: 40 }
offset: { x: 20, y: 20 }
background: { color: *accent }
- size: { width: 80, height: 80 }
offset: { x: 16, y: 16 }
origin: end bottom
icon: { value: material/star, color: '#ffffff' }
@@ -0,0 +1,8 @@
site_name: Layer composition
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- social:
cache: false
cards_layout: composed
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">
<rect width="1200" height="630" fill="#2d4059"/>
<circle cx="1050" cy="400" r="180" fill="#4d6680"/>
</svg>

After

Width:  |  Height:  |  Size: 179 B

@@ -0,0 +1,8 @@
---
title: Home title
description: Home description.
---
# Home
The global layout options supply the visible title and description.
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect width="100" height="100" rx="12" fill="#ffffff"/>
<circle cx="50" cy="50" r="32" fill="#2d4059"/>
</svg>

After

Width:  |  Height:  |  Size: 181 B

@@ -0,0 +1,15 @@
---
title: Page title
description: Page description.
social:
cards_layout_options:
background_image: null
color: '#ffe0a3'
logo: null
title: Page card title
description: Page card description
---
# Override
This page merges its layout options with the global settings.
+23
View File
@@ -0,0 +1,23 @@
site_name: Layout options
site_description: Default card description.
site_url: https://example.test/docs/
site_dir: site
theme:
name: material
palette:
primary: teal
font:
text: Roboto
plugins:
- social:
cache: false
cache_dir: social-cache
cards_layout: default
cards_layout_options:
background_color: '#2d4059'
background_image: docs/background.svg
color: '#ffffff'
font_family: Roboto
logo: docs/logo.svg
title: Global title
description: Global description
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="168">
<rect width="320" height="168" fill="red"/>
</svg>

After

Width:  |  Height:  |  Size: 119 B

+5
View File
@@ -0,0 +1,5 @@
---
title: Before title
---
# Home
@@ -0,0 +1,6 @@
tags:
og:image: '{{ image.url }}'
x:title: '{{ page.meta.get("title", page.title) }}'
size: { width: 320, height: 168 }
layers:
- background: { image: docs/background.svg, color: transparent }
+9
View File
@@ -0,0 +1,9 @@
site_name: Cached social lifecycle
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- social:
cache: true
cache_dir: social-cache
cards_layout: flat
+20
View File
@@ -0,0 +1,20 @@
[
{
"path": "docs/background.svg",
"before": "fill=\"red\"",
"after": "fill=\"blue\"",
"changes": ["cards"]
},
{
"path": "docs/index.md",
"before": "Before title",
"after": "After title",
"changes": ["pages"]
},
{
"path": "layouts/flat.yml",
"before": "color: transparent",
"after": "color: '#73628a'",
"changes": ["cards"]
}
]
+6
View File
@@ -0,0 +1,6 @@
---
title: A bright idea
description: A card with a logo icon.
---
# Home
+11
View File
@@ -0,0 +1,11 @@
site_name: Logo icon cards
site_url: https://example.test/docs/
site_dir: site
theme:
name: material
icon:
logo: material/star
plugins:
- social:
cache: false
cache_dir: social-cache
@@ -0,0 +1,4 @@
social:
cards_layout_options:
background_color: '#445566'
label: inherited
@@ -0,0 +1 @@
# Inherited options
@@ -0,0 +1,8 @@
---
social:
cards_layout_options:
background_color: '#778899'
label: page
---
# Page options
+1
View File
@@ -0,0 +1 @@
# Global options
@@ -0,0 +1,6 @@
tags:
og:image: '{{ image.url }}'
x:label: '{{ layout.label }}'
size: { width: 320, height: 168 }
layers:
- background: { color: '{{ layout.background_color }}' }
+12
View File
@@ -0,0 +1,12 @@
site_name: Social metadata
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- meta
- social:
cache: false
cards_layout: flat
cards_layout_options:
background_color: '#112233'
label: global
+1
View File
@@ -0,0 +1 @@
# Guide
+1
View File
@@ -0,0 +1 @@
# Home
@@ -0,0 +1,6 @@
tags:
og:image: '{{ image.url }}'
x:instance: '{{ layout.label }}'
size: { width: 320, height: 168 }
layers:
- background: { color: '{{ layout.color }}' }
+17
View File
@@ -0,0 +1,17 @@
site_name: Social instances
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- social:
cache: false
cards_layout: flat
cards_dir: assets/cards/first
cards_include: ['index.md']
cards_layout_options: { label: first, color: '#102030' }
- social:
cache: false
cards_layout: flat
cards_dir: assets/cards/second
cards_include: ['guide.md']
cards_layout_options: { label: second, color: '#405060' }
@@ -0,0 +1 @@
# Home
@@ -0,0 +1,5 @@
tags:
og:image: '{{ image.url }}'
size: { width: 320, height: 168 }
layers:
- background: { color: '#123456' }
+7
View File
@@ -0,0 +1,7 @@
site_name: Social without URL
site_dir: site
theme: { name: material }
plugins:
- social:
cache: false
cards_layout: flat
@@ -0,0 +1,6 @@
tags:
og:image: '{{ image.url }}'
og:url: '{{ page.canonical_url }}'
size: { width: 320, height: 168 }
layers:
- background: { color: '#486284' }
+1
View File
@@ -0,0 +1 @@
# Guides
+1
View File
@@ -0,0 +1 @@
# Home
@@ -0,0 +1 @@
# Nested index
+11
View File
@@ -0,0 +1,11 @@
site_name: Flat URL paths
site_url: https://example.test/docs/
site_dir: site
use_directory_urls: false
theme: { name: material }
plugins:
- social:
cache: false
cards_dir: assets/cards
cards_layout_dir: card-layouts
cards_layout: flat
@@ -0,0 +1 @@
# Home
@@ -0,0 +1,5 @@
tags:
og:image: '{{ image.url }}'
size: { width: 320, height: 168 }
layers:
- background: { image: docs/does-not-exist.svg }
@@ -0,0 +1,10 @@
site_name: Recoverable social error
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- social:
cache: false
cards_layout: missing-image
log: true
log_level: ignore
@@ -0,0 +1 @@
# Advanced
@@ -0,0 +1 @@
# Home
@@ -0,0 +1,7 @@
tags:
og:image: '{{ image.url }}'
x:source: '{{ page.file.src_uri }}'
x:page: '{{ page.url }}'
size: { width: 320, height: 168 }
layers:
- background: { color: '#357192' }
@@ -0,0 +1,8 @@
site_name: Social template context
site_url: https://example.test/docs/
site_dir: site
theme: { name: material }
plugins:
- social:
cache: false
cards_layout: context
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<circle cx="50" cy="50" r="48" fill="#1f2937"/>
<path d="M25 50h50M50 25v50" stroke="#ffffff" stroke-width="10"/>
</svg>

After

Width:  |  Height:  |  Size: 191 B

@@ -0,0 +1,7 @@
---
title: Italic
social:
cards_layout: exact
---
# Exact font face
@@ -0,0 +1,6 @@
---
title: Theme colors
description: Card colors and logo come from the theme.
---
# Home
@@ -0,0 +1,9 @@
---
title: PNG logo
description: This page uses a raster logo through its layout options.
social:
cards_layout_options:
logo: docs/assets/logo.png
---
# PNG logo
@@ -0,0 +1,9 @@
---
title: Heavier font variant
description: This page selects the Black variant of Roboto.
social:
cards_layout_options:
font_variant: Black
---
# Variant
@@ -0,0 +1,12 @@
tags:
og:image: '{{ image.url }}'
size: { width: 1200, height: 630 }
layers:
- background: { color: '#ffc107' }
- size: { width: 900, height: 150 }
offset: { x: 64, y: 180 }
typography:
content: '{{ page.title }}'
color: '#000000'
line: { amount: 1, height: 1.25 }
font: { family: Roboto, variant: Black, style: Italic }
+21
View File
@@ -0,0 +1,21 @@
site_name: Theme defaults
site_description: Theme-derived card settings.
site_url: https://example.test/docs/
site_dir: site
theme:
name: material
logo: assets/logo.svg
palette:
- media: '(prefers-color-scheme: light)'
primary: amber
accent: deep orange
- media: '(prefers-color-scheme: dark)'
primary: indigo
accent: pink
font:
text: Roboto
plugins:
- social:
cache: false
cache_dir: social-cache
cards_layout: default
+2 -2
View File
@@ -103,7 +103,7 @@ def test_excludes_pages_resources_and_extra_templates(
"private-note.md": "# Excluded root note\n",
"root.tmp": "temporary",
"files/archive.tmp": "temporary",
"files/archive.TMP": "case matters",
"files/uppercase.TMP": "case matters",
"files/archive.bin": "binary",
"export.html": "{{ must_not_render() }}",
}.items():
@@ -141,7 +141,7 @@ def test_excludes_pages_resources_and_extra_templates(
"index.html",
"guide/keep/index.html",
"guide/private-note/index.html",
"files/archive.TMP",
"files/uppercase.TMP",
"theme.txt",
):
assert (site / name).exists(), name
+401
View File
@@ -0,0 +1,401 @@
# Copyright (c) 2025-2026 Zensical and contributors
# SPDX-License-Identifier: MIT
# All contributions are certified under the DCO
"""Integration tests for native MkDocs Material social compatibility."""
from __future__ import annotations
import struct
from typing import TYPE_CHECKING, Any
import pytest
import zensical
if TYPE_CHECKING:
from pathlib import Path
_BUILD_OPTIONS: dict[str, Any] = {"clean": False, "strict": False}
def _png_size(path: Path) -> tuple[int, int]:
"""Read PNG dimensions without adding an imaging test dependency."""
data = path.read_bytes()
assert data.startswith(b"\x89PNG\r\n\x1a\n")
return struct.unpack(">II", data[16:24])
def _write_project(root: Path) -> Path:
"""Create a social project whose layout requires no network access."""
docs = root / "docs"
layouts = root / "layouts"
docs.mkdir()
(docs / "guide").mkdir()
layouts.mkdir()
(docs / "index.md").write_text(
"---\ntitle: 'A social & card'\n---\n# Home\n",
encoding="utf-8",
)
(docs / "guide" / "index.md").write_text(
"---\nsocial:\n cards: false\n---\n# Guide\n",
encoding="utf-8",
)
(layouts / "plain.yml").write_text(
"""\
tags:
og:type: website
og:title: "{{ page.title }}"
og:image: "{{ image.url }}"
og:image:width: "{{ image.width }}"
x:layout: "{{ layout.label }}"
size: { width: 320, height: 168 }
layers:
- background: { color: "#123456" }
""",
encoding="utf-8",
)
config = root / "mkdocs.yml"
config.write_text(
"""\
site_name: Social
site_url: https://example.com/docs
theme:
name: material
plugins:
- material/social:
cache: false
cards_layout: plain
cards_include: ['*.md']
cards_layout_options:
label: measured
""",
encoding="utf-8",
)
return config
def test_generates_custom_card_and_injects_metadata(tmp_path: Path) -> None:
"""YAML, MiniJinja, raster output and HTML injection work together."""
config = _write_project(tmp_path)
zensical.build(str(config), _BUILD_OPTIONS)
card = tmp_path / "site" / "assets" / "images" / "social" / "index.png"
assert _png_size(card) == (320, 168)
html = (tmp_path / "site" / "index.html").read_text()
assert '<meta property="og:type" content="website" />' in html
assert '<meta property="og:title" content="A social &amp; card" />' in html
assert (
'<meta property="og:image" '
'content="https://example.com/docs/assets/images/social/index.png" />'
in html
)
assert '<meta property="og:image:width" content="320" />' in html
assert '<meta property="x:layout" content="measured" />' in html
assert html.index('<meta property="og:type"') < html.index("</head>")
assert not (
tmp_path
/ "site"
/ "assets"
/ "images"
/ "social"
/ "guide"
/ "index.png"
).exists()
def test_material_namespace_and_multiple_instances_are_preserved(
tmp_path: Path,
) -> None:
"""Canonical and namespaced aliases remain ordered plugin instances."""
config = _write_project(tmp_path)
config.write_text(
"""\
site_name: Social
site_url: https://example.com/docs
theme:
name: material
plugins:
- material/social:
cache: false
cards_dir: assets/cards/first
cards_layout: plain
- social/second:
cache: false
cards_dir: assets/cards/second
cards_layout: plain
""",
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
first = tmp_path / "site" / "assets" / "cards" / "first" / "index.png"
second = tmp_path / "site" / "assets" / "cards" / "second" / "index.png"
assert first.is_file()
assert second.is_file()
def test_later_instance_owns_a_shared_card_path(tmp_path: Path) -> None:
"""Output collisions follow MkDocs plugin ordering deterministically."""
config = _write_project(tmp_path)
(tmp_path / "layouts" / "wide.yml").write_text(
(tmp_path / "layouts" / "plain.yml")
.read_text()
.replace("width: 320, height: 168", "width: 640, height: 320"),
encoding="utf-8",
)
config.write_text(
"""\
site_name: Social
site_url: https://example.com
theme: { name: material }
plugins:
- social: { cache: false, cards_layout: plain }
- social/last: { cache: false, cards_layout: wide }
""",
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
card = tmp_path / "site" / "assets" / "images" / "social" / "index.png"
assert _png_size(card) == (640, 320)
def test_without_site_url_generates_but_does_not_link_card(
tmp_path: Path,
) -> None:
"""A missing site URL suppresses metadata without suppressing output."""
config = _write_project(tmp_path)
config.write_text(
config.read_text().replace("site_url: https://example.com/docs\n", "")
)
zensical.build(str(config), _BUILD_OPTIONS)
assert (
tmp_path / "site" / "assets" / "images" / "social" / "index.png"
).is_file()
assert (
'<meta property="og:image"'
not in (tmp_path / "site" / "index.html").read_text()
)
def test_cache_tracks_local_image_contents(tmp_path: Path) -> None:
"""A changed layout dependency invalidates the persistent card cache."""
config = _write_project(tmp_path)
layout = tmp_path / "layouts" / "plain.yml"
layout.write_text(
layout.read_text().replace(
'background: { color: "#123456" }',
'background: { image: "{{ config.docs_dir }}/background.svg" }',
),
encoding="utf-8",
)
background = tmp_path / "docs" / "background.svg"
background.write_text(
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">'
'<rect width="10" height="10" fill="red"/></svg>',
encoding="utf-8",
)
config.write_text(config.read_text().replace(" cache: false\n", ""))
zensical.build(str(config), _BUILD_OPTIONS)
card = tmp_path / "site" / "assets" / "images" / "social" / "index.png"
before = card.read_bytes()
background.write_text(
background.read_text().replace('fill="red"', 'fill="blue"'),
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
assert card.read_bytes() != before
cached = (tmp_path / ".cache/plugin/social/cards").glob("*.png")
assert len(list(cached)) == 2
def test_unrelated_images_do_not_invalidate_cached_cards(
tmp_path: Path,
) -> None:
"""The invalidation signal does not become part of the card cache key."""
config = _write_project(tmp_path)
config.write_text(
config.read_text().replace(" cache: false\n", "")
)
zensical.build(str(config), _BUILD_OPTIONS)
cache = tmp_path / ".cache/plugin/social/cards"
assert len(list(cache.glob("*.png"))) == 1
(tmp_path / "docs" / "unused.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>',
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
assert len(list(cache.glob("*.png"))) == 1
def test_concurrent_identical_cards_do_not_share_temporary_files(
tmp_path: Path,
) -> None:
"""Equal card digests remain safe across concurrent page jobs."""
config = _write_project(tmp_path)
config.write_text(
config.read_text().replace(
" cache: false\n",
" cache: false\n concurrency: 8\n",
)
)
for index in range(8):
(tmp_path / "docs" / f"page-{index}.md").write_text(
f"# Page {index}\n",
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
directory = tmp_path / "site/assets/images/social"
assert all(
(directory / f"page-{index}.png").is_file()
for index in range(8)
)
def test_supports_bundled_image_only_layout(tmp_path: Path) -> None:
"""All layouts shipped by upstream are available without Python imaging."""
config = _write_project(tmp_path)
background = tmp_path / "docs" / "background.svg"
background.write_text(
'<svg xmlns="http://www.w3.org/2000/svg" width="2" height="1">'
'<rect width="2" height="1" fill="orange"/></svg>',
encoding="utf-8",
)
config.write_text(
config.read_text()
.replace(
" cards_layout: plain\n",
" cards_layout: default/only/image\n",
)
.replace(
" cards_layout_options:\n label: measured\n",
" cards_layout_options:\n"
" background_image: docs/background.svg\n",
)
)
zensical.build(str(config), _BUILD_OPTIONS)
card = tmp_path / "site/assets/images/social/index.png"
assert _png_size(card) == (1200, 630)
html = (tmp_path / "site/index.html").read_text()
assert '<meta property="og:title" content="A social &amp; card" />' in html
def test_root_readme_is_a_homepage_in_layout_context(tmp_path: Path) -> None:
"""MkDocs treats a root README exactly like a root index page."""
config = _write_project(tmp_path)
(tmp_path / "docs/index.md").rename(tmp_path / "docs/README.md")
layout = tmp_path / "layouts/plain.yml"
layout.write_text(
layout.read_text().replace(
' x:layout: "{{ layout.label }}"\n',
' x:layout: "{{ layout.label }}"\n'
' x:homepage: "{{ page.is_homepage }}"\n',
),
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
html = (tmp_path / "site/index.html").read_text()
assert '<meta property="x:homepage" content="true" />' in html
def test_honors_documented_log_levels_and_strict_mode(tmp_path: Path) -> None:
"""Ignored errors stay quiet, while warnings fail strict builds."""
config = _write_project(tmp_path)
layout = tmp_path / "layouts/plain.yml"
layout.write_text(
layout.read_text().replace(
'background: { color: "#123456" }',
'background: { image: "missing.png" }',
),
encoding="utf-8",
)
config.write_text(
config.read_text().replace(
" cache: false\n",
" cache: false\n log_level: ignore\n",
)
)
zensical.build(str(config), _BUILD_OPTIONS)
assert not (
tmp_path / "site/assets/images/social/index.png"
).exists()
config.write_text(
config.read_text().replace("log_level: ignore", "log_level: warn")
)
with pytest.raises(RuntimeError, match="strict flag"):
zensical.build(
str(config), {"clean": False, "strict": True}
)
def test_rejects_unknown_social_configuration(tmp_path: Path) -> None:
"""Rust validation reports the precise plugin option path."""
config = _write_project(tmp_path)
config.write_text(
config.read_text().replace(
" cache: false\n", " unknown: true\n"
)
)
with pytest.raises(ValueError, match=r"plugins\.social\.unknown"):
zensical.build(str(config), _BUILD_OPTIONS)
def test_rejects_invalid_page_overrides_before_error_logging(
tmp_path: Path,
) -> None:
"""Page configuration errors remain fatal when render errors are logged."""
config = _write_project(tmp_path)
index = tmp_path / "docs/index.md"
index.write_text(
"---\nsocial:\n cards_layout_options: invalid\n---\n# Home\n",
encoding="utf-8",
)
with pytest.raises(RuntimeError, match="cards_layout_options"):
zensical.build(str(config), _BUILD_OPTIONS)
def test_warns_for_deprecated_options(
tmp_path: Path, capfd: pytest.CaptureFixture[str]
) -> None:
"""Accepted legacy settings point users to their layout replacements."""
config = _write_project(tmp_path)
config.write_text(
config.read_text().replace(
" cache: false\n",
" cache: false\n"
" cards_color: red\n"
" cards_font: Roboto\n",
)
)
zensical.build(str(config), _BUILD_OPTIONS)
stderr = capfd.readouterr().err
assert "'cards_color' option" in stderr
assert "'cards_font' option" in stderr
+18 -2
View File
@@ -165,6 +165,7 @@ _PLUGIN_UNSUPPORTED_OPTIONS = {
"pipeline",
"prebuild_index",
),
"social": (),
"table-reader": (),
"tags": (
"tags_compare",
@@ -568,6 +569,13 @@ def _apply_defaults(config: dict, path: str) -> dict:
elif "theme" not in config:
config["theme"] = {}
font_explicit = "font" in config["theme"]
configured_icons = config["theme"].get("icon")
logo_icon_explicit = (
isinstance(configured_icons, dict)
and configured_icons.get("logo") is not None
)
# Set defaults for custom theme directory
set_default(config["theme"], "custom_dir", None, str)
@@ -597,6 +605,7 @@ def _apply_defaults(config: dict, path: str) -> dict:
config["theme"] = {**theme_config, **config["theme"]}
theme = config["theme"]
theme["font_explicit"] = font_explicit
# Set defaults for theme name
# (we do this after loading the theme configuration
@@ -629,6 +638,7 @@ def _apply_defaults(config: dict, path: str) -> dict:
# Set defaults for theme icons
icon = set_default(theme, "icon", {}, dict)
icon["logo_explicit"] = logo_icon_explicit
set_default(icon, "repo", None, str)
set_default(icon, "annotation", None, str)
set_default(icon, "tag", {}, dict)
@@ -1738,13 +1748,15 @@ def _convert_plugins(value: Any, config: dict) -> dict:
tags: list[dict[str, Any]] = []
blogs: list[dict[str, Any]] = []
rss: list[dict[str, Any]] = []
social: list[dict[str, Any]] = []
def add(name: Any, data: Any) -> None:
"""Canonicalize Material aliases while preserving tag instances."""
if not isinstance(name, str):
raise ConfigurationError("Plugin names must be strings")
name = name.removeprefix("material/")
if name not in _PLUGIN_UNSUPPORTED_OPTIONS:
canonical = "social" if name.startswith("social/") else name
if canonical not in _PLUGIN_UNSUPPORTED_OPTIONS:
return
if data is None:
data = {}
@@ -1752,13 +1764,15 @@ def _convert_plugins(value: Any, config: dict) -> dict:
raise ConfigurationError(f"{name} configuration must be a mapping")
else:
data = dict(data)
for option in _PLUGIN_UNSUPPORTED_OPTIONS[name]:
for option in _PLUGIN_UNSUPPORTED_OPTIONS[canonical]:
data.pop(option, None)
if name == "tags":
_reject_unknown_options("tags", data, _TAGS_SUPPORTED_OPTIONS)
tags.append({"name": name, "config": data})
elif name == "blog":
blogs.append({"name": name, "config": data})
elif canonical == "social":
social.append({"name": name, "config": data})
elif name == "rss":
rss.append({"name": name, "config": _normalize_rss(data)})
else:
@@ -1791,6 +1805,8 @@ def _convert_plugins(value: Any, config: dict) -> dict:
plugins["blogs"] = blogs
plugins["rss"] = rss
# Preserve ordered social instances for native validation and rendering.
plugins["social"] = social
# Search is enabled by default, even when it isn't explicitly configured.
search = plugins.pop("search", {})
_reject_unknown_options("search", search, {"enabled", "separator"})