API#
Core#
mkdocs_related_content.plugin.RelatedContentPlugin(*args, **kwargs)
#
Bases: BasePlugin[RelatedContentPluginConfig]
flowchart TD
mkdocs_related_content.plugin.RelatedContentPlugin[RelatedContentPlugin]
click mkdocs_related_content.plugin.RelatedContentPlugin href "" "mkdocs_related_content.plugin.RelatedContentPlugin"
Main class for the Related Content Mkdocs plugin.
Computes, for every tagged page, a list of related pages based on
shared tags, and exposes it to the Jinja context so a theme override
(e.g. overrides/partials/related.html) can render it.
Build lifecycle used, and why#
on_files - read every page's frontmatter straight from disk to
build a full-site tags index. This has to happen before
any page is rendered, and Page.meta isn't populated
yet at this point - see util.Util.build_tags_index.
on_nav - precompute every page's related pages in one pass, now
that the index (built from disk, not from the render
pipeline) is guaranteed complete.
on_page_context - expose the precomputed result for the current page.
on_post_build - optionally write our own tags.json-shaped export,
only when Material's tags plugin isn't already doing so.
Instantiation.
on_config(config)
#
First event called on build, right after config is loaded.
See: https://www.mkdocs.org/user-guide/plugins/#on_config
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
MkDocsConfig
|
global configuration object. |
required |
Returns:
| Type | Description |
|---|---|
MkDocsConfig
|
The (possibly unmodified) global configuration object. |
on_files(files, config)
#
Called after the global Files collection is populated.
This is the step that makes the whole plugin possible: every File
is known at this point, so we can read every page's YAML
frontmatter straight from disk and know every tag on the site -
something Page.meta can't give us yet, page by page.
See: https://www.mkdocs.org/user-guide/plugins/#on_files
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
files
|
Files
|
global files collection. |
required |
config
|
MkDocsConfig
|
global configuration object. |
required |
Returns:
| Type | Description |
|---|---|
Files
|
The (unmodified) global files collection. |
on_nav(nav, config, files)
#
Called after the site navigation is created.
Precomputes every page's related pages in a single pass. Safe to do
here - rather than lazily on the first on_page_context call -
because self.tags_index no longer depends on the page render
order: it was built from raw frontmatter in on_files.
See: https://www.mkdocs.org/user-guide/plugins/#on_nav
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
nav
|
Navigation
|
global navigation object. |
required |
config
|
MkDocsConfig
|
global configuration object. |
required |
files
|
Files
|
global files collection. |
required |
Returns:
| Type | Description |
|---|---|
Navigation
|
The (unmodified) global navigation object. |
on_page_context(context, page, config, nav)
#
Called after the page context is created, before rendering.
Exposes related_pages, the configured section title and CSS class
to Jinja.
See: https://www.mkdocs.org/user-guide/plugins/#on_page_context
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
context
|
dict
|
template context for the current page. |
required |
page
|
Page
|
|
required |
config
|
MkDocsConfig
|
global configuration object. |
required |
nav
|
Navigation
|
global navigation object. |
required |
Returns:
| Type | Description |
|---|---|
dict
|
The enriched template context. |
on_post_build(config)
#
Called once the whole site has been built.
Writes our own tags.json-shaped export, but only as a fallback:
if Material's tags plugin is active and already exports its own
JSON, we skip this to avoid a redundant, possibly inconsistent file.
See: https://www.mkdocs.org/user-guide/plugins/#on_post_build
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
MkDocsConfig
|
global configuration object. |
required |
on_startup(*, command, dirty)
#
Runs once at the very beginning of an mkdocs invocation.
See: https://www.mkdocs.org/user-guide/plugins/#on_startup
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
command
|
Literal['build', 'gh-deploy', 'serve']
|
the command Mkdocs was invoked with. |
required |
dirty
|
bool
|
whether |
required |
mkdocs_related_content.config.RelatedContentPluginConfig
#
Bases: Config
flowchart TD
mkdocs_related_content.config.RelatedContentPluginConfig[RelatedContentPluginConfig]
click mkdocs_related_content.config.RelatedContentPluginConfig href "" "mkdocs_related_content.config.RelatedContentPluginConfig"
Configuration for the Related Content plugin for Mkdocs.
mkdocs_related_content.constants
#
Models#
mkdocs_related_content.models.PageTagsEntry(src_uri, url, tags=list(), fallback_title=None, manual_links=())
dataclass
#
Lightweight, timing-safe representation of one page's tags.
Built directly from a page's YAML frontmatter during on_files - i.e.
straight from disk, before Mkdocs has read/rendered a single page.
This is what makes it possible to know every page's tags before
computing relatedness for the very first one.
title starts as a best-effort guess (frontmatter title, else first
Markdown heading, else a humanized filename) and gets superseded by the
real Page.title wherever that's already available - see
util.Util.resolve_related_pages.
manual_links = ()
class-attribute
instance-attribute
#
This page's own related_content.links frontmatter, in the order
they were listed - see ManualLink.
mkdocs_related_content.models.RelatedPage(title, url, shared_tags=list(), score=0.0, manual=False)
dataclass
#
One entry exposed to the Jinja template for a single related page.
manual = False
class-attribute
instance-attribute
#
True for a page listed in the current page's own related_content.links
frontmatter, rather than computed from tag similarity.
url
instance-attribute
#
Relative to the page it's attached to (see Util.resolve_related_pages),
ready to use as-is in an href - unlike PageTagsEntry.url, which is
root-relative. Exception: an external manual link's url is used
exactly as written in the frontmatter, unchanged.
Integrations#
mkdocs_related_content.integrations.theme_material_base.IntegrationMaterialThemeBase(mkdocs_config)
#
Shared base to detect whether the active theme is Material (or MaterialX).
Same pattern as mkdocs-rss-plugin's own IntegrationMaterialThemeBase:
https://github.com/Guts/mkdocs-rss-plugin
Integration instantiation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mkdocs_config
|
MkDocsConfig
|
Mkdocs website configuration object. |
required |
is_mkdocs_theme_material(mkdocs_config=None)
#
Check if the theme set in mkdocs.yml is material or not.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mkdocs_config
|
MkDocsConfig | None
|
Mkdocs website configuration object. |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
True if the theme's name is 'material' or 'materialx'. |
mkdocs_related_content.integrations.theme_material_tags.IntegrationMaterialTags(mkdocs_config, switch_force=True)
#
Bases: IntegrationMaterialThemeBase
flowchart TD
mkdocs_related_content.integrations.theme_material_tags.IntegrationMaterialTags[IntegrationMaterialTags]
mkdocs_related_content.integrations.theme_material_base.IntegrationMaterialThemeBase[IntegrationMaterialThemeBase]
mkdocs_related_content.integrations.theme_material_base.IntegrationMaterialThemeBase --> mkdocs_related_content.integrations.theme_material_tags.IntegrationMaterialTags
click mkdocs_related_content.integrations.theme_material_tags.IntegrationMaterialTags href "" "mkdocs_related_content.integrations.theme_material_tags.IntegrationMaterialTags"
click mkdocs_related_content.integrations.theme_material_base.IntegrationMaterialThemeBase href "" "mkdocs_related_content.integrations.theme_material_base.IntegrationMaterialThemeBase"
Detects Material's built-in tags plugin and exposes its configuration.
Timing note - why we read config, not runtime state#
Material's tags plugin builds its own page/tag mapping incrementally,
page by page, exactly like our own plugin would if it used the same
hooks. That mapping - and the tags.json file it can export - is only
complete once every page has been processed, i.e. at on_post_build.
That's after every page's Jinja context (and therefore its rendered
HTML) has already been produced.
Consequence: we can't read tags.json, nor the tags plugin's internal
mapping, to fill related_pages for a given page - that data doesn't
exist yet at that point of the build (see plugin.py's on_files for how
we work around this for our own index).
What is safely available at any point of the build is the tags
plugin's static configuration (e.g. tags_allowed). We use it so
our own, independently-computed tag index stays consistent with what
readers see on Material's tags index page.
Integration instantiation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mkdocs_config
|
MkDocsConfig
|
Mkdocs website configuration object. |
required |
switch_force
|
bool
|
set to False to disable this integration even if Material's tags plugin is enabled. Defaults to True. |
True
|
allowed_tags
property
#
Allow-list configured on Material's tags plugin (tags_allowed), if any.
tags_allowed isn't a plain list of strings: Material's TagSet
config option validates it into a set of its own Tag objects
(material.plugins.tags.structure.tag.Tag). That class overrides
__eq__ to only compare against other Tag instances - comparing
one to a plain str raises AssertionError instead of returning
False. Tag.__str__ does return the plain tag name, so we
normalize to strings here rather than leaking Tag objects into
util.build_tags_index, which compares against plain frontmatter
strings.
Returns:
| Type | Description |
|---|---|
set[str] | None
|
None when no restriction is configured (every tag found in a |
set[str] | None
|
page's frontmatter is considered valid), otherwise the set of |
set[str] | None
|
allowed tag names, as plain strings. |
exports_own_json()
#
Best-effort check of whether Material's tags plugin will export its
own tags.json for this build.
This inspects a couple of plausible, undocumented attribute names
that have been used across Material versions and is deliberately
conservative: if none match, it assumes export is active (Material's
default), so our own fallback export in plugin.on_post_build stays
opt-in rather than risking a silent duplicate file.
Returns:
| Type | Description |
|---|---|
bool
|
True if Material's tags plugin appears to already export JSON. |
Utils#
mkdocs_related_content.util.Util
#
Standalone helpers for the Related Content plugin.
Kept free of any Mkdocs event wiring so the scoring logic can be unit
tested on its own - same separation of concerns as mkdocs-rss-plugin's
own Util class.
build_tags_index(files, allowed_tags=None, match_path_pattern=None)
#
Build a {src_uri: PageTagsEntry} index by reading YAML frontmatter directly from disk.
This intentionally does not rely on Page.meta: at on_files
time, no page has been read by Mkdocs yet, so this is the only way
to know every page's tags before rendering the first one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
files
|
Files
|
Mkdocs' global Files collection. |
required |
allowed_tags
|
set[str] | None
|
optional allow-list (typically Material's tags
plugin |
None
|
match_path_pattern
|
Pattern[str] | None
|
optional compiled regex (see the plugin's
|
None
|
A page can also opt itself out via its own YAML frontmatter,
regardless of match_path:
This has the exact same effect as failing match_path - the page
is entirely absent from the returned index - but is a per-page
author decision rather than a site-wide, path-based one. Unlike
the hide: [related_content] frontmatter convention (a purely
template-level check - see docs/index.md), this is read and
enforced by the plugin itself, before any candidate scoring runs.
A page can also pin its own hand-picked suggestions, which take
priority over automatically-computed ones (see
resolve_related_pages):
related_content:
links:
- some-page.md
- Custom label: some-other-page.md
- https://example.org/some-external-resource/
Same nav-like syntax as MaterialX/Material's blog plugin links
property - a bare string, or a single-key {label: target} mapping
for an explicit title (always used as-is, no auto-resolution) - see
_parse_manual_links. A target may be internal (src_uri-style) or
an external URL. A page with links but no tags is still indexed
(it just never gets automatic suggestions of its own, nor is it
ever suggested to others via scoring).
Returns:
| Type | Description |
|---|---|
dict[str, PageTagsEntry]
|
The index, keyed by |
dict[str, PageTagsEntry]
|
it has neither a (valid) tag nor any |
compute_related_pages(tags_index, min_score, max_related, tag_weights=None)
#
Precompute every page's related pages.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tags_index
|
dict[str, PageTagsEntry]
|
output of |
required |
min_score
|
float
|
minimum score for a page to be considered related. |
required |
max_related
|
int
|
maximum number of related pages kept per page -
shared between automatic and manual suggestions together,
see |
required |
tag_weights
|
dict[str, float] | None
|
optional per-tag weight passed to |
None
|
A page's own related_content.links targets (see build_tags_index)
are kept out of its automatic candidates here, before max_related
caps the list - a page already pinned manually never wastes a slot
that a fresh automatic candidate could fill instead.
Returns:
| Type | Description |
|---|---|
dict[str, list[tuple[float, str]]]
|
{src_uri: [(score, related_src_uri), ...]}, sorted by descending |
dict[str, list[tuple[float, str]]]
|
score and capped to |
compute_tag_weights(tags_index)
staticmethod
#
Weight each tag by the inverse of how many pages use it.
A tag shared by only 2 pages out of 500 is a much stronger signal
of relatedness than a tag half the site uses - this gives the
former a weight of 0.5 and the latter 1/250 = 0.004, so it
counts for much less in jaccard_score.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tags_index
|
dict[str, PageTagsEntry]
|
output of |
required |
Returns:
| Type | Description |
|---|---|
dict[str, float]
|
{tag: weight}, weight = 1 / number of pages using that tag. |
jaccard_score(tags_a, tags_b, tag_weights=None)
staticmethod
#
Similarity between two tag sets: |intersection| / |union|.
Computes the union via inclusion-exclusion
(|a| + |b| - |a & b|, or the weighted equivalent below) instead
of building the union set itself (a | b) - same result, one
fewer set allocation per call, which matters here since this runs
inside compute_related_pages's O(N^2) loop.
When tag_weights is given (see compute_tag_weights), each tag
contributes its weight instead of a flat 1 to both the
intersection and union sums - rare, shared tags count for more
than common ones. The result stays between 0 and 1 either way,
since the intersection is always a subset of the union.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tags_a
|
set[str]
|
tags of the first page. |
required |
tags_b
|
set[str]
|
tags of the second page. |
required |
tag_weights
|
dict[str, float] | None
|
optional per-tag weight, e.g. from
|
None
|
Returns:
| Type | Description |
|---|---|
float
|
A score between 0 (no shared tag) and 1 (identical tag sets). |
resolve_related_pages(related, tags_index, current_tags, files, current_page_url, manual_links=(), max_related=5, max_manual_related=5)
#
Turn automatic candidates and the page's own manual links into
template-ready RelatedPage objects, manual ones first.
Prefers the real, fully-resolved Page.title whenever Mkdocs has
already produced it (i.e. the related page was processed earlier in
the build), and falls back to the frontmatter/heading guess from
tags_index otherwise. In practice the two are almost always
identical. A manual link's own label (see ManualLink), when
given, always wins over both - it's an explicit author choice.
RelatedPage.url is computed relative to current_page_url (via
Mkdocs' own get_relative_url) rather than left root-relative like
tags_index[...].url is. Root-relative URLs (e.g. sub/page-b/)
only resolve correctly from the site root - used directly as
href="{{ r.url }}" in a template, they produce a wrong link for
any page that isn't at the site root itself. Resolving them here
means every consumer gets a correct, ready-to-use href without
having to remember to apply Mkdocs' | url Jinja filter themselves.
An external manual link (see _is_external_link) is the one
exception - its url is used exactly as written, since there's no
internal file to resolve it against.
Manual links (related_content.links, see build_tags_index) are
resolved first, capped to max_manual_related, and always shown
regardless of min_score - an explicit author choice overrides the
automatic threshold. Remaining slots, up to max_related in total,
go to automatic candidates - compute_related_pages already keeps
those free of anything also listed manually, so no duplicate work
and no wasted slot.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
related
|
list[tuple[float, str]]
|
|
required |
tags_index
|
dict[str, PageTagsEntry]
|
output of |
required |
current_tags
|
set[str]
|
tags of the page these related pages are for. |
required |
files
|
Files
|
Mkdocs' global Files collection, used to reach the real
|
required |
current_page_url
|
str
|
root-relative URL of the page these related
pages are for ( |
required |
manual_links
|
tuple[ManualLink, ...]
|
this page's own |
()
|
max_related
|
int
|
total number of related pages shown, manual and automatic combined. |
5
|
max_manual_related
|
int
|
maximum number of manual links honored within that total. |
5
|
Returns:
| Type | Description |
|---|---|
list[RelatedPage]
|
Ready-to-render related pages: manual ones first (in the |
list[RelatedPage]
|
author's own order), then automatic ones by descending score. |
write_tags_json(tags_index, files, site_dir, filename)
#
Write a tags.json-shaped export, as a fallback when Material's
tags plugin isn't available to produce one itself.
Meant to be called from on_post_build: by then every page has been
processed, so titles/URLs are fully resolved - unlike the index
built in on_files, which only has best-effort titles.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tags_index
|
dict[str, PageTagsEntry]
|
output of |
required |
files
|
Files
|
Mkdocs' global Files collection. |
required |
site_dir
|
str
|
build output directory ( |
required |
filename
|
str
|
name of the JSON file to write, relative to |
required |