Versioning Architecture
How the enterprise docs are versioned, generated, and edited after theversioning-architecture migration.
For day-to-day editing instructions, see section 8 of CONTRIBUTING.md
(“Editing the Enterprise docs”). This file is the architecture reference.
Quick reference
The pipeline at a glance
Why inline substitution? Earlier shells usedIn parallel:import PageContent from "..."+<PageContent version="8.2" .../>and the snippet referenced{props.version}at runtime. That pattern silently broke at React client-side hydration —props.versionresolved toundefined, leaving every internal doc link pointing at/docs/enterprise/undefined/.... Build-time substitution sidesteps the whole runtime-prop pipeline. Seeda262ba3for the fix andscripts/tests/test_generate_shells.pyfor the contract.
Source tree
Per-version differences — three patterns
Pick based on how much differs between versions:Pattern A — prop (small differences)
Use when: a word, label, UI path, or link target differs.generate_shells.py substitutes every props.X reference in the snippet body
with the version-specific JS literal at build time. For the snippet above, the
emitted 8.2 shell contains
Go to **{"Cloud Fabric > UserVPN > select the VPN Gateways tab > click the Edit icon next to the gateway"}**,
and the 9.0 shell contains the 9.0 string. Mintlify renders the JSX expression
as plain text. No runtime prop propagation involved.
Heads up on[label]({props.X})markdown links: prop substitution emits a JSON-encoded string (with literal quotes), which is valid as a JSX expression but not as a markdown URL. Always use<a href={props.X}>label</a>JSX form when the URL comes from a prop. Seeuser-vpn-duo-authentication-{v82,v90}.mdxfor the canonical pattern.
Multi-line<a>JSX is handled at build time — write it however you like. Mintlify’s MDX parser silently fails on multi-line<a href={...}>label</a>blocks inline-adjacent to paragraph text — the page renders empty andmint validatereports “file does not exist.” But prettier (run by the husky pre-commit hook) reformats single-line JSX into multi-line on every save, so a source-level rule against multi-line is unenforceable. Instead,generate_shells.py::_flatten_multiline_anchor_jsxcollapses every anchor in the shell body to single-line form before Mintlify ever sees it. Source snippets may contain either shape; the emitted shell is always parseable.
Pattern B — inline conditional JSX (a single element differs)
Use when: an inline element (a sentence, a single component) exists in some versions only — and only when the content is plain JSX, not markdown.## Heading or
* List item inside {props.showX && <>...</>}. If you need a conditional
markdown block, use Pattern C.
Pattern C — per-version snippet (a whole section or page differs)
Use when: more than ~20% of a page differs, or you need a conditional markdown block. A per-version snippet overrides the shared snippet for just that one version — it is not a full duplicate set. Keep the barefoo.mdx as the default for
every other version, and add only the versions that actually diverge:
-vXX.mdx file for a version whose content is identical to the bare
file; that version already inherits it.
Suffix is v + the version-props.json key with dots stripped: 8.2 → v82,
9.0 → v90. (Dots in the basename break Mintlify’s snippet path resolver —
discovered in Phase 4 browser validation.)
generate_shells.py resolves, per version, in this order:
- A per-version snippet suffixed for that version (
foo-v82.mdx), if present. - Otherwise, the bare shared snippet (
foo.mdx), if present. - Otherwise, the page does not exist for that version (no shell emitted).
docs/enterprise/8.2/guides/uservpn/duo-authentication and
docs/enterprise/9.0/guides/uservpn/duo-authentication both resolve to pages,
each with whichever content its version resolved to).
Why this matters when adding a new version: because resolution falls back to
the bare file, launching version N+1 requires a new -v<N+1>.mdx snippet
only for pages whose content genuinely changes in that release. Every page
with no override for the new version keeps rendering its shared content
automatically — no clone-and-review pass over the full page set. (Before this
fallback existed, generate_shells.py skipped any version with no matching
suffixed file, so launching 10.0 without a -v100.mdx for every one of the
existing 8.2/9.0 splits silently 404’d — see AVX-78447. That’s fixed by the
fallback, not by writing more files.)
Common operations
Add a new shared page
- Create
snippets/enterprise/pages/<section>/<...>/foo.mdxwith frontmatter (title, description, keywords) and body. - Add
/docs/enterprise/{VERSION}/<section>/<...>/footonavigation-parser/subsections/shared/<section>.jsonat the right position. make all→make devto preview.
Add a page that exists only in some versions
- Create the snippet at
snippets/enterprise/pages/<...>/foo.mdx(orfoo-v90.mdxfor a per-version variant — see Pattern C). - In
navigation-parser/subsections/overrides/<section>-v<ver>.json, add an entry: make all.
Remove a page from a newer version
Add a bare-stringremove entry to
navigation-parser/subsections/overrides/<section>-v9.0.json:
make all. The remove entry serves two purposes:
generate_nav.pyreads the path and hides the page from 9.0’s generated sidebar.check_version_gaps.pytreats the gap as documented (no CI failure).
docs/enterprise/9.0/<...>/foo.mdx is still emitted from the
shared snippet — the page is reachable via direct URL but absent from the
sidebar.
Audit lives in git, not JSON. The commit that adds the remove entry is the
audit record. Write the commit message so it answers “why?” — cite the Jira
ticket, doc-team decision, or deprecation date. To recover the audit log on
demand, run python3 scripts/removals-log.py — it walks git log -p for the
override files and reports who introduced each entry, when, and the commit
subject.
Object form {"path": "/docs/.../foo"} is also accepted for forward
compatibility with richer-shaped entries, but the bare-string form is preferred.
Both pass the gap-check without warnings.
Add a new variable per version
- Add the key to every version entry in
navigation-parser/version-props.json(gap-check doesn’t enforce this, butlint_docs.pyflags{props.unknown_key}references). - Reference it in any snippet as
{props.myNewKey}.
Add a new version (e.g., 10.0)
- Add
{"id": "10.0", "label": "10.0", "default": true, "nav_managed": true, "shells_managed": true}tonavigation-parser/versions.json(set previous default tofalsefirst). - Add
"10.0": { "version": "10.0", ... }toversion-props.jsonwith all the per-version values. - If 10.0 nav diverges from 9.0, create
navigation-parser/subsections/overrides/<section>-v10.0.json. - Add
"@include:enterprise-10.0.json"to theversionsarray innavigation-parser/base.json. make all. Every bare (shared) snippet’s shell appears atdocs/enterprise/10.0/automatically — no new snippet file needed. Only pages that already had a Pattern C per-version split (-v9X.mdx) need a-v100.mdxcounterpart, and only if 10.0’s content for that specific page actually differs from what the fallback would render.python3 scripts/check_nav_shells.py— confirms every nav entry generated for 10.0 actually resolved to a shell file.check_version_gaps.pyonly compares nav JSON between versions; it does not know whether a shell was actually written, so a page that’s correctly in the nav but silently missing its shell (this is exactly what happened when 10.0 launched — see AVX-78447) will not be caught by it.
generate_shells.py, generate_nav.py, image filenames,
or doc-link patterns — the version-aware substitution is fully parameterized via
props.version, and per-version snippets are a fallback override, not a
required file-per-version.
Add or update a reference/ui/ screenshot
Screenshots under images/reference/ui/ use a bare-default + per-version-
override resolver in generate_shells.py, analogous to Pattern C for snippets —
no <basename>-9.0.<ext> copy is needed just because a new version shipped.
- UI unchanged for the new version: do nothing. The snippet keeps its
<img src={"/images/reference/ui/<subsection>/<basename>-" + props.version + ".png"} />reference untouched;generate_shells.pyresolves it to the existing<basename>.pngbare default at build time. - UI changed for one version only: add
<basename>-<version>.png(e.g.<basename>-8.2.png) at/images/reference/ui/<subsection>/— an explicit override that resolves for that version only. Every other version keeps resolving to the bare default. No snippet edit needed either way; the"..." + props.version + ".png"expression in the snippet is unchanged syntax, and the resolver — not the snippet — decides which literal file each version gets. make all, then confirm the new/changed file is picked up:grep -rn "<basename>" docs/enterprise/*/reference/ui/.
scripts/migrate_ui_ref_images_to_bare_default.py for the one-time migration
that established this inventory from the older
<basename>-8.2.<ext>/<basename>-9.0.<ext> (no bare file) convention.
Build commands
mintlify dev on this repo’s size (~2,200 images + 550 snippets) needs an
elevated file-descriptor limit. If dev wedges with “EMFILE: too many open
files”, restart with:
CI checks
Every PR runs (via.github/workflows/build-docs.yml):
make clean && make all— must produce a completedocs.json.python3 scripts/check_version_gaps.py— fails if a page exists in an older version but not in the newer one AND the gap isn’t documented as aremoveentry innavigation-parser/subsections/overrides/<section>-v<newer>.json. The audit trail for each removal lives in git history; runpython3 scripts/removals-log.pyto recover it.python3 scripts/lint_docs.py— fails on missing frontmatter (title/description/keywords), undefined{props.X}references, literal/docs/enterprise/<version>/doc links (must be{props.version}-templated), and relativeimages/refs (must be absolute/images/<section>/).python3 -m pytest scripts/tests/— must pass.
docs.json and the docs/enterprise/{8.2,9.0,10.0}/ shells are gitignored
build output (see .gitignore) produced by make all — they are not committed
to the repo, so there is no committed-output freshness gate in CI. Mintlify’s
deploy workflow (.github/workflows/publish-mint-deploy.yml) regenerates them
on every push/PR and force-pushes the result to the main-prod branch
(production) or a preview-<branch> branch (per-PR preview); Mintlify serves
from those deploy branches, not from anything checked into this branch. Do not
hand-edit the generated files — edit the snippets.
Architecture decisions (and why)
See also
- section 8 of
CONTRIBUTING.md— day-to-day editing instructions README.md— repo layout, build system, and content-management overview