magebitcom / module-documentation
Module documentation viewer for Magento 2 admin
Magebit_Documentation
A Magento 2 admin documentation viewer. Any module can drop Markdown files into a folder, register
that folder in etc/documentation.xml, and the pages appear under Docs in the admin menu —
rendered, searchable, and permission-aware.
Requirements
- PHP 8.1 or newer
- Magento 2.4 (
magento/framework^103.0)
Installation
composer require magebitcom/module-documentation
bin/magento module:enable Magebit_Documentation
bin/magento setup:upgrade
bin/magento cache:flush
Open Docs in the admin menu. The menu item is top level, not nested under System.
Quick start
Put Markdown files in your module and point a documentation.xml at them.
app/code/Vendor/Module/
├── Docs/
│ ├── 1-getting-started.md
│ ├── 2-configuration.md
│ └── images/
│ └── flow.png
└── etc/
└── documentation.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magebit_Documentation:etc/documentation.xsd">
<module name="Vendor_Module" title="My Module" sortOrder="10">
<documentation name="Guide" path="Docs" sortOrder="10"/>
</module>
</config>
Then clear the right cache. After a documentation.xml change, run bin/magento cache:clean config —
the merged configuration is stored in the config cache, so cleaning magebit_documentation alone will
not pick it up. After adding, renaming or removing a Markdown file,
bin/magento cache:clean magebit_documentation is enough. See Caching for the whole
picture.
documentation.xml
<module>
| Attribute | Required | Description |
|---|---|---|
name |
yes | Module the sections belong to, e.g. Vendor_Module. Groups the sidebar. |
title |
no | Label in the sidebar. Falls back to the module name. |
sortOrder |
no | Position among modules, lower first. Default 100; ties break on title. |
icon |
no | View-asset path of an icon shown beside the module title in the documentation sidebar, e.g. Vendor_Module::images/icon.svg. Not the admin menu icon. |
<documentation>
One folder of Markdown pages.
| Attribute | Required | Description |
|---|---|---|
name |
yes | Section label in the sidebar. Also the key that merges declarations. |
path |
yes | Folder holding the Markdown files. See Paths. |
acl |
no | ACL resource an admin must hold to see the section. |
sortOrder |
no | Position among the module's sections, lower first. Default 100. |
<changelog>
One Markdown file rather than a folder — for a CHANGELOG.md that already sits in the module root.
<module name="Vendor_Module" title="My Module">
<documentation name="Guide" path="Docs"/>
<changelog path="CHANGELOG.md"/>
</module>
| Attribute | Required | Description |
|---|---|---|
path |
yes | Markdown file, relative to the module or written as Vendor_Other::CHANGELOG.md. |
name |
no | Section label. Default Changelog. |
acl |
no | ACL resource an admin must hold to see the section. |
sortOrder |
no | Position among the module's sections. Default 1000, so it lands last. |
Paths
path is resolved against the module that declared it, or against another module when it is written
as Vendor_Other::Docs:
<documentation name="Guide" path="Docs"/>
<documentation name="API" path="Docs/Api"/>
<documentation name="Notes" path="Vendor_Other::Docs"/>
A path may not leave the module it resolves against — ../OtherModule/Docs is refused, and so is any
.. that would climb out. Use the Vendor_Other:: form instead.
A section whose path does not resolve is dropped from the tree without an error, which is what
magebit:documentation:validate catches. A section whose path resolves but whose folder holds no
Markdown is dropped too; validate passes on that one, so check magebit:documentation:list — a
section missing from that table has an empty folder.
Adding sections to another module's group
A satellite module can file its own pages under the parent's heading by declaring the parent's name:
<!-- Vendor_SubModule/etc/documentation.xml -->
<module name="Vendor_Module">
<documentation name="Sub Module" path="Vendor_SubModule::Docs" sortOrder="40"/>
</module>
Writing pages
File names
- A numeric prefix sets the order and is stripped from the label:
1-getting-started.md→ Getting Started. -and_become spaces, and each word is capitalised.index.mdandreadme.mdare the section or folder overview: they are labelled Overview and sort first.- Sub-folders become collapsible categories, named by the same rules. Nesting stops at 10 levels.
- Files that are not
.mdare not pages. They can still be linked to as images.
Front matter
An optional YAML block at the very top of a page overrides the two things the file name decides:
---
title: Getting started with orders
order: 20
---
# Getting started
| Key | Type | Effect |
|---|---|---|
title |
string | Replaces the label derived from the file name. Blank values are ignored. |
order |
integer | Replaces the numeric file-name prefix. |
order must be an unquoted integer. order: "20" is a string, and a string is rejected: the page
keeps its file-name order and a warning naming the key and the file is written to the system log. Only
the first 8 KB of a file is read for front matter, so keep the block at the top.
Markdown
CommonMark with GitHub Flavored Markdown — tables, task lists, strikethrough, autolinks — plus heading
permalinks, footnotes and a table of contents. Raw HTML in a page is stripped, and unsafe link schemes
are refused. Links that leave the site open in a new tab and carry a small arrow.
Headings automatically fill the On this page panel beside the content, and the panel follows the
reader down the page.
GitHub-style callouts work as they do on GitHub — a blockquote opening with [!NOTE], [!TIP],
[!IMPORTANT], [!WARNING] or [!CAUTION] becomes a coloured box with a title:
> [!WARNING]
> Cleaning the wrong cache is the most common mistake.
Links between pages
Relative links ending in .md are rewritten to point at the other page:
See [configuration](2-configuration.md) and [the API](advanced/1-api.md).
A relative link without an extension is left exactly as written — [see](configuration) is not
turned into configuration.md. Add the extension.
Absolute URLs, anchors and mailto: links pass through untouched.
Images
Images are served by an admin controller, not by static content, so they do not need a deploy:

The path is resolved relative to the page and must stay inside the section folder — an image
outside it, or above it via .., returns a 404. Allowed types are png, jpg, jpeg, gif, svg
and webp, up to 8 MB. SVGs are served with a restrictive Content-Security-Policy, so an SVG that
pulls in external resources will not render them.
The module icon from documentation.xml is the other kind of image and follows the opposite rules:
it is an ordinary static view asset, resolved through getViewFileUrl(), so it lives under
view/adminhtml/web/ and does need bin/magento setup:static-content:deploy in production mode.
Images inside your pages need no deploy; the sidebar icon does.
Code blocks
Fenced blocks are highlighted in the browser by a bundled copy of highlight.js. The common languages
(php, javascript, json, xml, yaml, sql, bash, css, diff, ini, markdown and more) work out of the box.
dockerfile, nginx and twig ship as separate files and load on demand.
A language the bundle does not know is rendered as plain text and a warning is logged in the browser
console. To have an extra language ready before the first block that needs it, list it under Extra
Languages in the configuration.
To show a fenced block inside a fenced block, wrap the outer one in four backticks.
Diagrams
A fenced block with the language mermaid is drawn in the browser by a bundled copy of
Mermaid — flowcharts, sequence diagrams, class diagrams, Gantt charts and
the rest. Nothing is fetched from a CDN, so it works under the admin Content-Security-Policy. Mermaid is
only loaded on pages that contain a diagram. A diagram Mermaid cannot read keeps its source visible with
a short message above it.
Clicking an image or a drawn diagram opens it full screen; Escape or another click closes it.
Access control
The whole viewer is gated by Magebit_Documentation::documentation ("View Documentation"), which sits
under System in System → Permissions → User Roles.
Individual sections can require their own resource:
<documentation name="Internal" path="Docs/Internal" acl="Vendor_Module::internal_docs"/>
<!-- Vendor_Module/etc/acl.xml -->
<resource id="Magento_Backend::admin">
<resource id="Magento_Backend::system">
<resource id="Vendor_Module::internal_docs" title="Internal Documentation"/>
</resource>
</resource>
A section an admin may not see is removed from the tree, from search results and from the page and
image controllers. A module left with no visible section disappears entirely.
Name a resource that no acl.xml declares and Magento falls back to the role's blanket permission:
the section stays visible to a full-access administrator and vanishes for every restricted role. That
is hard to notice by hand, so magebit:documentation:validate checks it for you.
Configuration
Stores → Configuration → Magebit → Documentation
| Field | Path | Description |
|---|---|---|
| Syntax Theme | magebit_documentation/appearance/highlight_theme |
Colour theme for code blocks. |
| Extra Languages | magebit_documentation/appearance/extra_languages |
Extra highlight.js bundles loaded on every page. |
| Enable Search | magebit_documentation/search/enabled |
Shows the search box above the tree. |
Commands
bin/magento magebit:documentation:list
Prints every registered section with its module, title, resolved absolute path and page count. It
reads the tree unfiltered, because the command line has no admin session and an ACL-filtered tree
would come back empty. A section that does not resolve is already gone from that tree, so it will not
be listed — which is what the second command is for.
bin/magento magebit:documentation:validate
Exits 0 when every configured path resolves and every acl attribute names a resource that
acl.xml actually declares. Otherwise it prints one line per problem and exits 1. It reads the
merged documentation.xml directly, so it still sees the sections the tree has dropped.
Neither fault announces itself in the browser — a bad path removes the section for everyone, and a
bad acl removes it only for restricted roles — so run this in CI.
Caching
Three separate things are cached, and they are not all cleaned by the same command.
| What | Where it lives | Cleaned by |
|---|---|---|
The merged documentation.xml |
the config cache type | cache:clean config |
| The documentation tree | the magebit_documentation cache type, tagged with the config cache |
cache:clean magebit_documentation or cache:clean config |
| The search index | the magebit_documentation cache type, tagged with the config cache |
cache:clean magebit_documentation or cache:clean config |
Page text is not cached at all — it is read from disk on every request, so an edit inside a page is
visible immediately. Search will keep matching the old text until the index is rebuilt.
So, in practice:
# changed a documentation.xml — this is the one that also covers everything else
bin/magento cache:clean config
# only added, renamed or removed a Markdown file
bin/magento cache:clean magebit_documentation
The magebit_documentation type is listed as Documentation under System → Cache Management,
where it can also be switched off.
Extending
The public contracts live in Magebit\Documentation\Api. Take a preference on one to replace an
implementation.
interface DocumentationTreeInterface
{
/** @return array<string, ModuleDocsInterface> Keyed by module name, ordered by sort order */
public function get(): array;
public function getSection(string $moduleName, string $sectionName): ?SectionInterface;
/** @return array{module: ModuleDocsInterface, section: SectionInterface, page: PageInterface}|null */
public function getFirst(): ?array;
}
interface PageRepositoryInterface
{
public function getContent(string $moduleName, string $sectionName, string $relativePath): ?string;
public function getContentForSection(
string $moduleName,
SectionInterface $section,
string $relativePath
): ?string;
/** @return array<string, mixed> */
public function getFrontMatter(string $moduleName, string $sectionName, string $relativePath): array;
}
interface SearchIndexInterface
{
/** @return list<SearchHitInterface> Highest score first */
public function search(string $query, int $limit = 20): array;
}
interface MarkdownRendererInterface
{
/** @param array{module:string,section:string,path:string} $context */
public function render(string $markdown, array $context): string;
}
interface SyntaxHighlighterInterface
{
public function decorate(string $html): string;
}
interface PathResolverInterface
{
public function resolveSectionRoot(string $contextModule, string $configuredPath): ?string;
/** @param list<string> $allowedExtensions Lowercase, without the dot */
public function resolveFile(string $sectionRoot, string $relativePath, array $allowedExtensions): ?string;
/** @return array{path: string, fileName: string}|null */
public function resolveChangelogFile(string $contextModule, string $configuredPath): ?array;
}
interface DirectoryScannerInterface
{
public function scan(string $absoluteRoot): CategoryInterface;
}
DocumentationTreeInterface, PageRepositoryInterface::getContent() and SearchIndexInterface are
all filtered by the current admin's permissions. PageRepositoryInterface::getContentForSection() is
not — it takes an already-resolved section, and the caller owns the authorization check.
The data objects returned by these — ModuleDocsInterface, SectionInterface, CategoryInterface,
PageInterface, SearchHitInterface — are read-only value objects in Magebit\Documentation\Api\Data.
Turning a configured or requested path into a disk path happens only in Model\Path\Resolver and
Model\Scanner; reading the bytes of an already-resolved path, through the injected
Filesystem\Driver\File, also happens in Model\PageRepository, Controller\Adminhtml\Asset\Index
and Model\Config\Source\ShippedFiles.
Screenshots
Troubleshooting
Nothing changed after editing documentation.xml. You cleaned the wrong cache. That file is
merged into the config cache: bin/magento cache:clean config.
A section does not appear. Run bin/magento magebit:documentation:validate. If it passes, the
folder resolved but held no .md files, or your role lacks the section's ACL resource.
Pages are stale after adding a file. bin/magento cache:clean magebit_documentation.
The sidebar icon does not load. Unlike page images, it is a static view asset:
bin/magento setup:static-content:deploy.
A front-matter order is ignored. It was quoted. Write order: 20, not order: "20".
An image is broken. It must sit inside the section folder and end in an allowed extension.
Releasing
Releases are cut by hand. Open the Actions tab, pick the Release workflow and run it on
master — it refuses to run on any other branch.
The version number comes from the commit messages since the last tag, which are
conventional commits:
| Commits since the last tag | Next version |
|---|---|
A breaking change (feat!:, or a BREAKING CHANGE: footer) |
major, 1.2.3 → 2.0.0 |
A feat: |
minor, 1.2.3 → 1.3.0 |
Anything else that is listed, such as fix:, perf:, refactor:, docs: |
patch, 1.2.3 → 1.2.4 |
Only housekeeping (ci, chore, build, test, style) |
nothing — the run stops and says so |
Two inputs:
- bump —
autouses the table above. Pickpatch,minorormajorto force it instead. - dry_run — works out the version and the notes, prints both in the run summary and stops. Nothing
is committed, tagged or published.
A real run then regenerates CHANGELOG.md with git-cliff,
commits it as chore(release): release vX.Y.Z, tags that commit vX.Y.Z, pushes both, and publishes
a GitHub release whose notes are the new section of the changelog.
If master only accepts changes through pull requests, the workflow needs a way past that rule for
its one changelog commit. Give it a deploy key:
- Create a key pair:
ssh-keygen -t ed25519 -N "" -f release-key -C "release workflow". - Add
release-key.pubunder Settings → Deploy keys with Allow write access ticked. - Add the private key (
release-key) as a repository secret namedRELEASE_DEPLOY_KEY, then delete
both files locally. - In Settings → Rules, make sure Deploy keys is listed as a bypass actor for the rule set that
protectsmaster.
The push then goes over SSH as that deploy key. Without a protected branch the secret can stay unset
and the run's own token is used.
Entry formatting lives in cliff.toml.
License
MIT. See LICENSE.
Changelog
Every entry below is generated from this module's conventional commit messages.
1.1.0 - 2026-09-02
Features
- Add callouts, footnotes, heading tracking, image zoom and print styles (e417917)
- Draw mermaid fences as diagrams with a bundled mermaid.js (9a483d7)
Bug Fixes
- Push the release with a deploy key and make the commit and tag one atomic push (77bb99f)
1.0.0 - 2026-08-31
Features
- Add list and validate commands and rewrite the public documentation (d905eec)
- Mask an open book svg into the menu icon and shorten the rail label to Docs (cfff9be)
- Give the documentation menu item its own admin icon (9e02514)
- Rewrite the admin ui with a single-pass tree, toc, keyboard search and copy buttons (eac8cb1)
- Add the page, search and image controllers with their view models (a09c8dc)
- Search document content and headings instead of file names (e2ab6ca)
- Highlight code blocks with the bundled highlight.js and make the theme configurable (9cf9b83)
- Rewrite markdown links and images in the AST instead of the output HTML (e5f84d6)
- Read page content and front matter behind the ACL-filtered tree (493237d)
- Let front matter override the title and order a page sorts by (088e700)
- Build the documentation tree once and cache it before ACL filtering (bc4241c)
- Flatten documentation.xml into typed module and section records (d5c37c8)
- Scan documentation folders into an ordered category tree (d264bd0)
- Resolve section paths against the target module and block traversal (f3bd3e1)
- Add immutable data contracts for the documentation tree (3539e10)
Bug Fixes
- Ignore configured highlight languages that no longer ship (9ef6a71)
- Send validate failures to stderr and correct the cache, icon and cache-type documentation (defbdfa)
- Widen the narrow-width content padding so the h1 permalink stays inside it (8470a00)
- Announce search result selection and sidebar toggle target to screen readers (efa616b)
- Keep the current page's tree ancestors open after a reload (3cfd460)
- Scope the search highlight mark selector to the docs page (251af13)
- Darken the muted text colour so it clears the 4.5:1 contrast floor (3da4171)
- Swap the pale permalink mark for a hover-revealed # that no longer shifts headings (fddc8b9)
- Build the page with the framework factory so the admin menu block exists (510ee00)
- Sandbox served svg documents and cap the size of a served image (e5cbb6c)
- Drop front matter and emphasis from indexed text and clean headings before collecting them (4b5a051)
- Load each highlight language on its own request and keep admin comments translatable (34298b9)
- Offer only light themes and fall back when the stored theme no longer ships (959670c)
- Support anchored and encoded documentation links and contain every render failure (847ab94)
- Warn the author when a front matter title or order cannot be used (fe9ffd9)
- Read front matter behind a byte order mark and always close the file handle (39da34c)
- Return the changelog file name from the resolver and cover the missing tree cases (a7ad127)
- Give the documentation cache its own type and clear it with the config cache (988a182)
- Make module metadata merging last-wins and read sections in document order (2b1a9be)
- Strip only markdown suffixes, cap scan depth and tighten the scanner tests (bdc9c66)
- Tighten the documentation path guard and cover its refusal branches (2fd26af)
- Exclude vendor/docs from phpstan, fix icon/categories docs, cover title fallback (0dd5996)
Refactoring
- Rename EnvironmentFactory to EnvironmentBuilder (2978a42)
- Remove the unused languagePath setting from the highlight loader (eda23eb)
- Drop the last phpcs:ignore from the directory scanner and give it a class docblock (b8bfce9)
- Drop the dark theme pairing and ship a light only viewer (64c5f53)
- Split shipped file names on the driver's slash instead of basename (faf4cb3)
- Remove the old implementation and add module static-analysis gates (664a3c4)
Documentation
- Correct the icon description and say where the file driver is used (1cefe96)
0.0.1 - 2026-02-10
Bug Fixes
- Package name (a8f8693)
Requires 6
| Package | Constraint |
|---|---|
| ext-dom | * |
| ext-mbstring | * |
| league/commonmark | ^2.10 |
| magento/framework | ^103.0 |
| php | ^8.1 |
| symfony/yaml | ^5.4 || ^6.0 || ^7.0 |
Compatibility
Each Magento release line is installed on its supported PHP versions, then the module is built (DI compilation + static-content deploy) and its unit and integration suites are run. The matrix shows the lines and PHP versions the module is confirmed to install and run on. Code-quality results further down (phpstan, phpcs, …) are reported separately and never affect compatibility.
Code Quality
Advisory checks against the module's source. Static analysis runs once across the whole module; PHPStan re-runs per Magento + PHP version because resolvable symbols differ between releases. These NEVER affect the Compatibility badge. A phpcs finding can't make a module incompatible.
Static analysis
Coding standards (phpcs), mess detection (phpmd), copy-pasted code (cpd), PHP cross-version compatibility, composer.json validity. Each runs once for the whole module.
PHPStan
Type-checks the module's PHP against a real Magento install at the configured gate level. Re-runs per Magento and PHP version because resolvable symbols differ between releases.
Tests
Unit and integration suites, run for each applicable Magento and PHP version. A test failure speaks to the module's behaviour, not its compatibility with a Magento line, so it is reported here separately and never reddens the compatibility matrix.
Unit tests
Integration tests
| Magento | PHP 8.2 | PHP 8.3 | PHP 8.4 | PHP 8.5 |
|---|---|---|---|---|
| 2.4.7 | N/A | N/A | ||
| 2.4.8 | N/A | N/A | ||
| 2.4.9 | N/A | N/A |
Security
Security checks run directly against the module: an audit of its declared dependencies for known vulnerabilities (composer audit) and a scan of its source for malware and web-shell signatures. Each runs once. A malware detection fails the version outright.
More from magebitcom
View vendorMagebit Agentic Commerce extension
Magebit Universal Commerce extension
This module enables Montonio Hirepurchase with Hyva Checkout
Google Analytics 4 MCP tools for Magebit_Mcp — wraps the GA Data and Admin APIs as MCP tools so AI clients can query property metadata, run reports, run realtime reports, and run funnel reports against your store's analytics property.
Turn an existing module into recurring revenue.
If you already maintain a Magento 2 module on GitHub or GitLab, listing it on Packagento takes about five minutes. We mirror your tags, handle distribution signing, and route paid licenses through Stripe Connect, so you can keep shipping the way you already do.