PKB Tech Stack
- Status: Implemented
- Context: Planning a publishable Personal Knowledge Base (PKB) as a git-based static site
Requirements¶
A git repository containing Markdown files and Excalidraw diagrams, built into a static site via GitHub Actions and deployable to GitHub Pages. The repository must support three usage scenarios equally well:
Usage Scenario 1 (default): Text Editor + excalidraw.com¶
Any contributor with a text editor and a browser can:
- Edit
.mdfiles in any editor (VS Code, vim, etc.) - Create/edit diagrams on excalidraw.com and save
.excalidrawfiles into the repository - Build and preview the site locally
- Push changes; CI builds and deploys
No proprietary tooling required.
Usage Scenario 2: Obsidian + Excalidraw Plugin¶
A contributor with Obsidian (free for commercial use since Feb 2025) and the Excalidraw plugin installed can:
- Open the repository folder as an Obsidian vault
- Edit
.mdfiles with full Obsidian UX (callouts, graph view, backlinks) - Create/edit diagrams directly within Obsidian via the Excalidraw plugin
- Build and preview the site locally
- Push changes; CI builds and deploys
Obsidian is a first-class citizen but never a requirement.
Usage Scenario 3: CI (GitHub Actions)¶
On push to main (and on PRs for validation):
- Checkout repository (including large files)
- Build site
- Deploy to GitHub Pages
Cross-Platform Constraint¶
Local builds must work identically on Linux, macOS and Windows with minimal prerequisites.
Additional Constraints¶
- Excalidraw diagrams stored as
.excalidrawfiles (standard JSON format) with git LFS - The rendered site must support a light/dark mode toggle with diagrams adapting accordingly
- All tooling must be free for commercial use
- Single source of truth per diagram (no pre-rendered exports to keep in sync)
Tech Stack¶
Static Site Generator: MkDocs + Material for MkDocs¶
Why MkDocs:
- Python-based, cross-platform via
uv(single tool manages Python version + packages) - Massive plugin ecosystem (300+ plugins)
- Simple configuration (
mkdocs.yml) - Fast builds, live-reload dev server
Why Material for MkDocs:
- MIT-licensed, all features free (Insiders tier discontinued Nov 2025)
- Built-in light/dark mode toggle
- Excellent search, admonitions, tabs, content tabs, annotations
- Native Mermaid diagram support
- Responsive, accessible, well-maintained
Consideration: The MkDocs ecosystem is in transition. MkDocs 1.x is unmaintained (no releases
in 18 months). MkDocs 2.0 is a ground-up rewrite that
removes the plugin system entirely
and is incompatible with Material for MkDocs. Material for MkDocs 9.7.5 pins mkdocs<2 so builds
remain stable today. The Material team is building
Zensical as a drop-in replacement for MkDocs 1.x, preserving the plugin
API and theme compatibility. The expected migration path is: mkdocs → zensical (a dependency
swap, not a rewrite). Our plugin dependencies are small and simple, so adapting them if needed is
low-effort.
Navigation: mkdocs-awesome-pages-plugin¶
Why:
- Flexible navigation ordering without maintaining a full
nav:tree inmkdocs.yml - Supports
.pagesfiles for per-directory ordering and titles - Zero configuration beyond adding the plugin to
mkdocs.yml
Excalidraw Rendering: mkdocs-excalidraw¶
Why:
- Renders
.excalidrawfiles (standard JSON format) to SVG client-side using the official@excalidraw/excalidrawlibrary - Automatic dark/light mode support: detects Material's
data-md-color-mediaattribute viaMutationObserver, re-renders with appropriate theme on toggle - Single source of truth: only
.excalidrawfiles are committed (no pre-rendered SVGs) - Lightbox support via
mkdocs-glightbox
How it works:
- At build time:
is transformed into an<excalidraw-renderer src="diagram.excalidraw">custom element - At runtime: the browser fetches the
.excalidrawJSON and renders it to SVG with the current theme baked in - On theme toggle:
MutationObserverdetects the change and triggers a re-render
Consideration: Small project (8 stars as of May 2026). The implementation is thin (~200 lines) and uses the official Excalidraw rendering library. If unmaintained in future, it can be forked or replaced with a custom MkDocs hook.
Obsidian Compatibility: mkdocs-obsidian-support-plugin¶
Why:
- Converts Obsidian callouts (
> [!note]) to Material admonitions at build time - Handles Obsidian comments and tags
Convention: Standard Markdown links ([text](path.md)) are used throughout — not wiki-links.
Obsidian is configured via a shipped .obsidian/app.json with "useMarkdownLinks": true so that
its link autocomplete generates standard Markdown links.
Enforced: A pre-commit hook forbids Obsidian note embeds (exclamation mark followed by [[...]]) which have no standard
Markdown equivalent and would require additional build-time conversion.
Backlinks: mkdocs-backlinks-section-plugin¶
Why:
- Automatically appends a "Backlinks" section to the bottom of each page listing all pages that link to it
- Works with Material out of the box (no theme template modifications)
- Zero configuration beyond adding the plugin to
mkdocs.yml
Consideration: Small project (8 stars as of May 2026, MIT). Simple implementation, easy to fork if needed.
Python Toolchain: uv¶
Why:
- Single binary, installs in seconds, works on Linux/macOS/Windows
- Manages Python versions (via
.python-versionin the repo) - Manages dependencies (via
pyproject.tomlorrequirements.txt) - No system Python required:
uv run mkdocs servehandles everything - Deterministic installs via lockfile
Version Control: Git + LFS¶
Why LFS for .excalidraw files:
- Excalidraw JSON files can grow large (complex diagrams with many elements)
- Binary-like change patterns (JSON diffs are not meaningful for diagrams)
- Keeps the git history lightweight
Repository Structure¶
my-pkb/
├── docs/ # content root
│ ├── index.md
│ ├── some_topic.md # plain Markdown, standard [text](path.md) links
│ └── diagrams/
│ └── architecture.excalidraw # single source of truth per diagram
├── .scripts/ # pre-commit helper scripts
│ └── check_excalidraw_settings.py # validates Excalidraw plugin config
├── mkdocs.yml # MkDocs configuration
├── pyproject.toml # Python dependencies (managed by uv)
├── uv.lock # deterministic dependency resolution
├── .python-version # Python version pinning (e.g. 3.13)
├── .gitattributes # LFS rules
├── .gitignore # includes .obsidian/ selective tracking
├── .pre-commit-config.yaml # lint + format + guards
├── .markdownlint-cli2.jsonc # markdownlint rules
├── .pymarkdown.json # pymarkdown rules
├── .linkspector.yml # link checker config
├── .obsidian/ # shipped Obsidian vault config
│ ├── app.json # useMarkdownLinks, attachmentFolderPath
│ ├── backlink.json # backlink pane configuration
│ ├── community-plugins.json # enabled community plugins
│ ├── core-plugins.json # enabled/disabled core plugins
│ └── plugins/
│ └── obsidian-excalidraw-plugin/
│ └── data.json # plugin settings (file format, embeds, no auto-export)
├── .github/
│ └── workflows/
│ ├── deploy.yml # build + deploy
│ └── governance.yml # pre-commit + commit hygiene on PRs
└── README.md
Configuration Notes¶
.gitattributes¶
* text eol=lf
*.excalidraw filter=lfs diff=lfs merge=lfs -text
.python-version¶
3.13
pyproject.toml (dependencies)¶
[project]
name = "my-pkb"
requires-python = ">=3.13"
dependencies = [
# MkDocs 2.0 removes the plugin system and is incompatible with Material for MkDocs.
# Pin to <2 until Zensical (https://zensical.org/) is ready as a drop-in replacement.
# See: https://squidfunk.github.io/mkdocs-material/blog/2026/02/18/mkdocs-2.0/
"mkdocs>=1.6,<2",
"mkdocs-awesome-pages-plugin",
"mkdocs-material",
"mkdocs-obsidian-support-plugin",
"mkdocs-backlinks-section-plugin",
"mkdocs-excalidraw",
"mkdocs-glightbox",
]
mkdocs.yml (key sections)¶
site_name: My PKB
repo_url: https://github.com/your-org/your-pkb
edit_uri: edit/main/docs/
theme:
name: material
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
toggle:
icon: material/brightness-7
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
toggle:
icon: material/brightness-4
name: Switch to light mode
plugins:
- search
- awesome-pages
- obsidian-support
- backlinks_section
- excalidraw
- glightbox
markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- toc:
permalink: true
Obsidian Excalidraw Plugin Configuration¶
Enforced via shipped .obsidian/plugins/obsidian-excalidraw-plugin/data.json and validated by a
pre-commit hook. Contributors opening the folder as a vault get the correct settings
automatically — no manual configuration needed. However, the Excalidraw plugin itself must be
installed manually (see below).
.obsidian/app.json¶
Shipped in the repository so that contributors opening the folder as a vault get the correct defaults:
{
"useMarkdownLinks": true,
"attachmentFolderPath": "./"
}
| Setting | Value | Why |
|---|---|---|
useMarkdownLinks |
true |
Obsidian's link autocomplete generates standard Markdown links ([text](path.md)) instead of wiki-links ([[path]]) |
attachmentFolderPath |
"./" |
New attachments (including Excalidraw drawings) are created in the same folder as the active file, not at the vault root. This keeps diagrams alongside the Markdown files that reference them |
.obsidian/community-plugins.json¶
Tells Obsidian which community plugins are enabled:
["obsidian-excalidraw-plugin"]
.obsidian/ Version Control¶
The .obsidian/ directory contains a mix of shared configuration (safe to track) and
device-specific state (must be ignored). The .gitignore ignores everything by default, then
explicitly allows shared files:
# Obsidian: ignore everything by default, then allow shared config
.obsidian/*
!.obsidian/app.json
!.obsidian/backlink.json
!.obsidian/community-plugins.json
!.obsidian/core-plugins.json
!.obsidian/plugins/
.obsidian/plugins/*
!.obsidian/plugins/obsidian-excalidraw-plugin/
.obsidian/plugins/obsidian-excalidraw-plugin/*
!.obsidian/plugins/obsidian-excalidraw-plugin/data.json
Tracked files (shared configuration):
| File | Purpose |
|---|---|
app.json |
Markdown link format, attachment folder |
backlink.json |
Backlink pane configuration |
community-plugins.json |
List of enabled community plugins |
core-plugins.json |
Enabled/disabled core plugins |
plugins/obsidian-excalidraw-plugin/data.json |
Excalidraw plugin settings |
Ignored files (device-specific or auto-downloaded):
| File | Why |
|---|---|
workspace.json |
Editor state (open tabs, pane layout, cursor positions) — causes merge conflicts |
plugins/*/main.js |
Plugin runtime code — auto-downloaded from community registry |
plugins/*/manifest.json |
Plugin metadata — redundant with community-plugins.json |
plugins/*/styles.css |
Plugin styles — included in plugin download |
cache/ |
Internal cache — regenerated automatically |
Gotcha — Obsidian does not auto-install plugins: When a contributor opens the vault for the
first time, Obsidian reads community-plugins.json but silently ignores entries for plugins that
are not installed. There is no installation prompt or error notification. Contributors must
manually install the Excalidraw plugin via Settings > Community plugins. Document this in
a setup guide within the repository.
.obsidian/plugins/obsidian-excalidraw-plugin/data.json¶
{
"compatibilityMode": true,
"compress": false,
"autoexportSVG": false,
"autoexportPNG": false,
"embedWikiLink": false
}
| Setting | Value | Why |
|---|---|---|
compatibilityMode |
true |
Saves as .excalidraw (standard JSON), editable on excalidraw.com. Despite the name, this is the setting that produces the portable format — without it, files are saved as .excalidraw.md (Obsidian-specific markdown wrapper) regardless of other settings |
compress |
false |
Prevents zlib compression of drawing data. Compressed data requires the .excalidraw.md wrapper format, which is incompatible with mkdocs-excalidraw |
autoexportSVG |
false |
Rendering handled client-side by mkdocs-excalidraw; no exported SVGs to keep in sync |
autoexportPNG |
false |
Same reasoning — no pre-rendered exports |
embedWikiLink |
false |
When the plugin embeds a drawing into a document, use standard Markdown syntax () instead of Obsidian wiki-link syntax (exclamation mark followed by [[drawing.excalidraw]]). Wiki-link embeds are not valid Markdown and break the MkDocs build |
Gotcha — misleading setting names: The plugin's setting names are counterintuitive.
compatibilityMode sounds like a fallback, but it is the only way to get standard .excalidraw
output. useExcalidrawExtension (which appears in the plugin's full config) does not control
the .excalidraw extension — with useExcalidrawExtension: true, files are saved as
.excalidraw.md (Obsidian wrapper with the .excalidraw extension prefix). The
source code
confirms this logic.
Gotcha — compression forces .excalidraw.md: Even with compatibilityMode: true, if
compress is true (the plugin's default), drawing data is zlib-compressed and stored in a
fenced compressed-json code block inside an .excalidraw.md file. Always set compress: false.
A pre-commit hook (check-excalidraw-settings) validates these settings on every commit — see
below.
.pre-commit-config.yaml¶
Pre-commit hooks ensure Markdown quality and repository hygiene.
All hook revisions use full SHA pins with # frozen: comments for security and reproducibility.
repos:
# -- Validate as much json as we can --
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: f805888065fdb6162e1f800e50bb9460cbd223d6 # frozen: 0.37.2
hooks:
- id: check-jsonschema
name: Validate markdownlint-cli2 config schema
files: .markdownlint-cli2.jsonc
args: [--schemafile, 'https://raw.githubusercontent.com/DavidAnson/markdownlint-cli2/v0.18.1/schema/markdownlint-cli2-config-schema.json']
# -- Markdown linting and canonicalisation --
- repo: https://github.com/DavidAnson/markdownlint-cli2
rev: 996abf60411a8d954288ac9856aae7602b80cbda # frozen: v0.22.1
hooks:
- id: markdownlint-cli2
files: \.md$
args: [--config=.markdownlint-cli2.jsonc, --fix]
- repo: https://github.com/jackdewinter/pymarkdown
rev: 491b8945442244c6b03028a96e9a8bde9ad63400 # frozen: v0.9.37
hooks:
- id: pymarkdown
name: PyMarkdown linter
files: \.md$
args: [--disable-rules, MD046, fix]
pass_filenames: true
# -- Link validation --
- repo: local
hooks:
- id: linkspector
name: Check broken links (linkspector)
entry: linkspector check -c .linkspector.yml
language: node
additional_dependencies: ['@umbrelladocs/linkspector@0.5.3']
pass_filenames: false
files: \.md$
# -- Python formatting + linting --
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 0c7b6c989466a93942def1f84baf36ddfcd60c83 # frozen: v0.15.14
hooks:
- id: ruff-check
- id: ruff-format
# -- YAML formatting --
- repo: https://github.com/google/yamlfmt
rev: 21ca5323a9c87ee37a434e0ca908efc0a89daa07 # frozen: v0.21.0
hooks:
- id: yamlfmt
# -- GitHub Actions linting --
- repo: https://github.com/rhysd/actionlint
rev: 914e7df21a07ef503a81201c76d2b11c789d3fca # frozen: v1.7.12
hooks:
- id: actionlint
# -- Custom guards --
- repo: local
hooks:
- id: check-mailmap
name: Check .mailmap for inconsistencies
entry: uv run --script .scripts/check_mailmap.py
language: system
pass_filenames: false
always_run: true
- id: check-python-version
name: Check .python-version matches pyproject.toml requires-python
entry: uv run --script .scripts/check_python_version.py
language: system
pass_filenames: false
files: '^(\.python-version|pyproject\.toml)$'
- id: no-obsidian-embeds
name: Forbid Obsidian note embeds
entry: '!\[\['
language: pygrep
files: '\.md$'
- id: check-excalidraw-settings
name: Check Excalidraw plugin settings for mkdocs compatibility
entry: uv run --script .scripts/check_excalidraw_settings.py
language: system
pass_filenames: false
files: '^\.obsidian/plugins/obsidian-excalidraw-plugin/data\.json$'
- id: lowercase-no-whitespace-filenames
name: Forbid uppercase or whitespace in filenames
entry: Filenames must be lowercase with no whitespace
language: fail
files: '[\sA-Z]'
exclude: '^(README\.md|CONTRIBUTING\.md|\.github/CODEOWNERS|LICENSE|Makefile)$'
- id: no-pip-install-in-workflows
name: Ensure Python deps for GitHub workflows are tracked in pyproject.toml
entry: 'pip install'
language: pygrep
files: '^\.github/.*\.ya?ml$'
# -- General file hygiene (should go last as it fixes line endings) --
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0
hooks:
- id: trailing-whitespace
- id: check-added-large-files
- id: check-case-conflict
- id: mixed-line-ending
args: [--fix=lf]
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: pretty-format-json
args: [--autofix, --no-sort-keys, --indent=4]
- id: check-symlinks
- id: fix-byte-order-marker
Purpose of each layer:
| Layer | Hook | Role |
|---|---|---|
| Schema | check-jsonschema |
Validates linter config against upstream schema |
| Markdown lint | markdownlint-cli2 |
Structural rules (headings, lists, spacing) with auto-fix |
| Markdown lint | pymarkdown |
Complementary rules (tab handling, heading increments) with auto-fix |
| Link validation | linkspector |
Puppeteer-based link checking; catches broken links before they reach the site |
| Python lint | ruff |
Linting and formatting for Python scripts in .scripts/ |
| Format | yamlfmt |
Canonical YAML formatting (configs, frontmatter) |
| CI lint | actionlint |
Catches GitHub Actions workflow errors |
| Guard | check-mailmap (local) |
Detects missing or inconsistent .mailmap entries |
| Guard | check-python-version (local) |
Keeps .python-version in sync with pyproject.toml requires-python |
| Guard | no-obsidian-embeds (local) |
Prevents Obsidian embed syntax (exclamation mark followed by [[embed]]) entering the repo |
| Guard | check-excalidraw-settings (local) |
Validates Excalidraw plugin settings (compatibilityMode, compress, autoexportSVG, autoexportPNG, embedWikiLink) against required values for mkdocs-excalidraw compatibility |
| Guard | lowercase-no-whitespace-filenames (local) |
Enforces lowercase, no-whitespace filenames |
| Guard | no-pip-install-in-workflows (local) |
Ensures Python deps for CI are tracked in pyproject.toml |
| Hygiene | pre-commit-hooks |
Trailing whitespace, LF line endings, large files, BOM removal |
Configuration files to ship:
.markdownlint-cli2.jsonc— markdownlint rules (disable line length, allow frontmatter title).pymarkdown.json— pymarkdown rules (heading increments, list spacing).linkspector.yml— link checker config (ignoremailto:, localhost,.excalidrawreferences)
.markdownlint-cli2.jsonc¶
{
"config": {
"default": true,
// MD013: Line length — disabled; prose wraps naturally, enforcing length hurts readability
"MD013": false,
// MD028: Blank line inside blockquote — disabled; Obsidian callouts use this pattern
"MD028": false,
// MD029: Ordered list item prefix — allow both 1. 1. 1. and 1. 2. 3. styles
"MD029": { "style": "one_or_ordered" },
// MD033: No inline HTML — disabled elements can be added here if needed
"MD033": { "allowed_elements": [] },
// MD041: First line should be a top-level heading — recognise frontmatter title field
"MD041": { "level": 1, "front_matter_title": "^\\s*title\\s*[:=]" },
// MD046: Code block style — disabled; MkDocs Material admonitions use indented blocks
// that markdownlint misidentifies as indented code blocks
"MD046": false
}
}
Rationale for each override:
| Rule | Override | Why |
|---|---|---|
| MD013 | disabled | Prose-heavy PKB content should wrap naturally; hard line breaks create noisy diffs |
| MD028 | disabled | Obsidian callouts (> [!note]) produce blank lines inside blockquotes |
| MD029 | one_or_ordered |
Both 1. 1. 1. (easy reordering) and 1. 2. 3. (explicit) are acceptable |
| MD033 | empty allow list | No inline HTML by default; add elements as needed |
| MD041 | frontmatter-aware | Files use YAML frontmatter title: instead of a # heading on line 1 |
| MD046 | disabled | MkDocs Material admonitions use indented blocks that markdownlint misidentifies as indented code blocks |
.pymarkdown.json¶
{
"plugins": [],
"extensions": [],
"config": {
"line-length": {
"enabled": false
},
"no-hard-tabs": {
"enabled": true
},
"spaces-after-list-marker": {
"enabled": true,
"punctuation": "consistent"
},
"list-marker-space": {
"enabled": true
},
"ol-prefix": {
"enabled": true,
"style": "ordered"
},
"blanks-around-headings": {
"enabled": true
},
"heading-increment": {
"enabled": true
}
}
}
Rationale for each rule:
| Rule | Setting | Why |
|---|---|---|
line-length |
disabled | Same reasoning as MD013 — prose wraps naturally |
no-hard-tabs |
enabled | Spaces only; tabs render inconsistently across tools |
spaces-after-list-marker |
consistent | Enforces uniform spacing within a document |
list-marker-space |
enabled | Requires space after list markers (- item, not -item) |
ol-prefix |
ordered | Ordered lists use 1. 2. 3. (matches MD029 allowing both) |
blanks-around-headings |
enabled | Headings must have blank lines above/below for readability |
heading-increment |
enabled | No skipping heading levels (e.g., ## directly after ####) |
.linkspector.yml¶
Linkspector is a Puppeteer-based link checker that validates links by rendering pages in a headless browser, catching issues that simpler regex-based checkers miss.
dirs:
- .
fileExtensions:
- md
useGitIgnore: true
ignorePatterns:
- pattern: '^mailto:'
- pattern: 'http://127.0.0.1:8000'
- pattern: '.*\.excalidraw$'
timeout: 10000
retryCount: 3
Rationale for ignore patterns:
mailto:— not resolvable as HTTP links.127.0.0.1:8000— references to the locally served dev site..excalidraw— rendered client-side by the plugin; not resolvable as link targets.
GitHub Actions¶
Two workflows handle CI/CD:
deploy.yml — builds the site on every push and PR, deploys to GitHub Pages on push to
main:
name: Build and Deploy
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
lfs: true
- uses: astral-sh/setup-uv@v5
- name: Build site
run: uv run mkdocs build --strict
- uses: actions/upload-pages-artifact@v3
with:
path: site/
deploy:
if: github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/deploy-pages@v4
governance.yml — runs all pre-commit hooks on PRs and blocks fixup!/squash! commits
and merge commits on non-draft PRs, enforcing a clean linear history.
dependabot.yml — automated weekly dependency updates for GitHub Actions, uv and
pre-commit hooks.
Licensing Summary¶
| Component | Licence | Commercial Use |
|---|---|---|
| MkDocs | BSD-2-Clause | Free |
| Material for MkDocs | MIT | Free |
| mkdocs-awesome-pages-plugin | MIT | Free |
| mkdocs-excalidraw | MIT | Free |
| mkdocs-obsidian-support-plugin | MIT | Free |
| mkdocs-backlinks-section-plugin | MIT | Free |
| mkdocs-glightbox | MIT | Free |
| Obsidian (optional editor) | Proprietary | Free (since Feb 2025) |
| Obsidian Excalidraw Plugin | MIT | Free |
Open Questions¶
- MkDocs → Zensical migration: Monitor Zensical compatibility with
our plugins. When Zensical reaches stable and supports our plugin set, swap
mkdocsforzensicalinpyproject.toml. This should be a one-line dependency change if MkDocs 1.x API compatibility holds. Track progress via the Zensical compatibility table. - MkDocs 1.x security: MkDocs 1.x is unmaintained. No known vulnerabilities today, but monitor. If a security issue arises before Zensical is ready, evaluate pinning or patching.
- mkdocs-excalidraw maturity: Small project. Consider vendoring or forking if it becomes unmaintained.
- Graph view: Not available with this stack. Accept that this is a Quartz/Obsidian Publish feature we consciously trade away for simplicity and portability.
Backlinks¶
The following pages link to this page: