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>
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Home & Intro
|
||||
description: A concise & useful description.
|
||||
---
|
||||
|
||||
# Welcome
|
||||
@@ -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' }
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Journal
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
date: 2026-09-20
|
||||
---
|
||||
|
||||
# First post
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
date: 2026-09-21
|
||||
---
|
||||
|
||||
# Second post
|
||||
@@ -0,0 +1 @@
|
||||
# Home
|
||||
@@ -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' }
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: An accent card
|
||||
social:
|
||||
cards_layout: default/accent
|
||||
---
|
||||
|
||||
# Accent
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: A thoughtful beginning
|
||||
description: A concise introduction to the project.
|
||||
---
|
||||
|
||||
# Home
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: An inverted card
|
||||
social:
|
||||
cards_layout: default/invert
|
||||
---
|
||||
|
||||
# Invert
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: A variant card
|
||||
social:
|
||||
cards_layout: default/variant
|
||||
---
|
||||
|
||||
# Variant
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# Home
|
||||
|
||||
Global card generation is disabled for this page.
|
||||
@@ -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' }
|
||||
@@ -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' }
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Debug view
|
||||
@@ -0,0 +1,5 @@
|
||||
tags:
|
||||
og:image: '{{ image.url }}'
|
||||
size: { width: 320, height: 168 }
|
||||
layers:
|
||||
- background: { color: '#123456' }
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Home
|
||||
@@ -0,0 +1,5 @@
|
||||
tags:
|
||||
og:image: '{{ image.url }}'
|
||||
size: { width: 320, height: 168 }
|
||||
layers:
|
||||
- background: { color: '#102030' }
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# Home
|
||||
|
||||
The disabled plugin must not generate a card or social metadata.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
social:
|
||||
cards: true
|
||||
---
|
||||
|
||||
# Page opt-in
|
||||
|
||||
A page override must not re-enable a disabled plugin.
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Home
|
||||
@@ -0,0 +1,5 @@
|
||||
tags:
|
||||
og:image: '{{ image.url }}'
|
||||
size: { width: 320, height: 168 }
|
||||
layers:
|
||||
- background: { color: '#abcdef' }
|
||||
@@ -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 |
@@ -0,0 +1 @@
|
||||
# Image card
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: A bright idea
|
||||
description: A card with a logo icon.
|
||||
---
|
||||
|
||||
# Home
|
||||
@@ -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
|
||||
@@ -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 }}' }
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Guide
|
||||
@@ -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 }}' }
|
||||
@@ -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' }
|
||||
@@ -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' }
|
||||
@@ -0,0 +1 @@
|
||||
# Guides
|
||||
@@ -0,0 +1 @@
|
||||
# Home
|
||||
@@ -0,0 +1 @@
|
||||
# Nested index
|
||||
@@ -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
|
||||
|
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 }
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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 & 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
|
||||
@@ -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"})
|
||||
|
||||