Skip to main content

Versioning Architecture

How the enterprise docs are versioned, generated, and edited after the versioning-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 used 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.version resolved to undefined, leaving every internal doc link pointing at /docs/enterprise/undefined/.... Build-time substitution sidesteps the whole runtime-prop pipeline. See da262ba3 for the fix and scripts/tests/test_generate_shells.py for the contract.
In parallel:

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. See user-vpn-duo-authentication-{v82,v90}.mdx for 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 and mint validate reports “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_jsx collapses 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.
Caveat: markdown content (headings, lists, paragraphs) inside the fragment is treated as a literal string, not parsed as MDX. Don’t put ## 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 bare foo.mdx as the default for every other version, and add only the versions that actually diverge:
If every version’s content differs, omit the bare file and provide a suffixed snippet for each one — but that should be the exception, not the default. Do not create a -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:
  1. A per-version snippet suffixed for that version (foo-v82.mdx), if present.
  2. Otherwise, the bare shared snippet (foo.mdx), if present.
  3. Otherwise, the page does not exist for that version (no shell emitted).
The suffix is stripped from the output path so the URL is the same across versions (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

  1. Create snippets/enterprise/pages/<section>/<...>/foo.mdx with frontmatter (title, description, keywords) and body.
  2. Add /docs/enterprise/{VERSION}/<section>/<...>/foo to navigation-parser/subsections/shared/<section>.json at the right position.
  3. make all → make dev to preview.

Add a page that exists only in some versions

  1. Create the snippet at snippets/enterprise/pages/<...>/foo.mdx (or foo-v90.mdx for a per-version variant — see Pattern C).
  2. In navigation-parser/subsections/overrides/<section>-v<ver>.json, add an entry:
  3. make all.

Remove a page from a newer version

Add a bare-string remove entry to navigation-parser/subsections/overrides/<section>-v9.0.json:
Then make all. The remove entry serves two purposes:
  1. generate_nav.py reads the path and hides the page from 9.0’s generated sidebar.
  2. check_version_gaps.py treats the gap as documented (no CI failure).
The shell at 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

  1. Add the key to every version entry in navigation-parser/version-props.json (gap-check doesn’t enforce this, but lint_docs.py flags {props.unknown_key} references).
  2. Reference it in any snippet as {props.myNewKey}.

Add a new version (e.g., 10.0)

  1. Add {"id": "10.0", "label": "10.0", "default": true, "nav_managed": true, "shells_managed": true} to navigation-parser/versions.json (set previous default to false first).
  2. Add "10.0": { "version": "10.0", ... } to version-props.json with all the per-version values.
  3. If 10.0 nav diverges from 9.0, create navigation-parser/subsections/overrides/<section>-v10.0.json.
  4. Add "@include:enterprise-10.0.json" to the versions array in navigation-parser/base.json.
  5. make all. Every bare (shared) snippet’s shell appears at docs/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.mdx counterpart, and only if 10.0’s content for that specific page actually differs from what the fallback would render.
  6. python3 scripts/check_nav_shells.py — confirms every nav entry generated for 10.0 actually resolved to a shell file. check_version_gaps.py only 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.
That’s it. No edits to 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.py resolves it to the existing <basename>.png bare 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/.
This differs from Pattern A/C’s cascade rule: an image override resolves for its exact named version only, never inherited by a newer version with no override of its own — every basename must have a bare default file. See 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):
  1. make clean && make all — must produce a complete docs.json.
  2. 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 a remove entry in navigation-parser/subsections/overrides/<section>-v<newer>.json. The audit trail for each removal lives in git history; run python3 scripts/removals-log.py to recover it.
  3. 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 relative images/ refs (must be absolute /images/<section>/).
  4. 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