> ## Documentation Index
> Fetch the complete documentation index at: https://docs.aviatrix.com/llms.txt
> Use this file to discover all available pages before exploring further.

# VERSIONING

# 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

| Question | Answer |
| - | - |
| Where do I edit content? | `snippets/enterprise/pages/<rel>.mdx` |
| Where do I never edit? | `docs/enterprise/{8.2,9.0,10.0}/` — gitignored build output; regenerated by `make all` |
| How do I preview? | `make dev` |
| How do I add a new version? | Add to `navigation-parser/versions.json` + props for that version in `version-props.json` |
| How do I add a page only in some versions? | Use `subsections/overrides/<section>-v<ver>.json` |
| Where do per-version differences live? | `version-props.json` (small) → per-version snippets (large) |

***

## The pipeline at a glance

```
snippets/enterprise/pages/<rel>.mdx               (source of truth, you edit this)
  │
  │   generate_shells.py reads each snippet
  │   For each version V in version-props.json:
  │     inline the snippet body into docs/enterprise/V/<rel>.mdx with every
  │     `props.X` reference (props.version, props.uiPath_*, etc.) pre-substituted
  │     to a JS literal — the shell becomes a self-contained MDX page
  ▼
docs/enterprise/V/<rel>.mdx                       (build output, gitignored; produced by `make all` and served by Mintlify)
  │
  │   Mintlify dev / production renders the shell as an ordinary MDX page —
  │   no `import PageContent` indirection, no runtime prop propagation
  ▼
HTML rendered at /docs/enterprise/V/<rel>          (what users see)
```

> **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:

```
navigation-parser/subsections/shared/<section>.json     (cross-version nav template)
  ├─ navigation-parser/subsections/overrides/<section>-v<V>.json  (per-version insert/remove; audit lives in git)
  │
  │   generate_nav.py substitutes {VERSION} → V, applies overrides
  ▼
navigation-parser/subsections/generated/<section>-v<V>.json
navigation-parser/enterprise-v<V>.json
  │
  │   merge_json.py merges with base.json
  ▼
docs.json                                         (Mintlify reads this)
```

***

## Source tree

```
snippets/enterprise/pages/             ← edit content here
  guides/<...>/foo.mdx                 shared snippet (renders in every version)
  guides/<...>/foo-v82.mdx             per-version snippet — only emits a shell for 8.2
  guides/<...>/foo-v90.mdx             per-version snippet — only emits a shell for 9.0

images/<section>/                      ← shared images
  diagram.png                          renders identically in every version

images/reference/ui/<subsection>/      ← bare-default + override resolver (see below)
  ui-screenshot.png                    bare default — served to any version with no override
  ui-screenshot-8.2.png                override — served only to 8.2

navigation-parser/
  versions.json                        defines each version (id, default, nav_managed, shells_managed)
  version-props.json                   per-version values (UI labels, link targets, etc.)
  base.json                            top-level Mintlify docs.json scaffold
  subsections/
    shared/                            cross-version nav templates (uses {VERSION})
      guides.json
      concepts-architectures.json
      reference.json
    overrides/                         per-version insert/remove directives
      guides-v9.0.json
      concepts-architectures-v9.0.json (etc.)
    generated/                         build output (gitignored)
  generate_shells.py                   snippets/ → docs/enterprise/{V}/shells/
  generate_nav.py                      shared+overrides → generated/<section>-v<V>.json
  merge_json.py                        enterprise-v<V>.json → docs.json

scripts/
  check_version_gaps.py                CI: every page in an older version exists
                                       (or is removal-documented) in newer versions
  lint_docs.py                         CI: snippets are well-formed (frontmatter,
                                       no literal version paths, etc.)

docs/enterprise/                       BUILD OUTPUT (gitignored; regenerated by make all)
  8.2/  9.0/  10.0/                    regenerated by `make all` — not tracked in git
  older-versions/                      hand-authored legacy (7.x/8.0/8.1)

.github/workflows/build-docs.yml       CI: make all + gap-check + lint + pytest
.husky/pre-commit                      Local guard: runs lint-staged (skips generated output)
```

***

## 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.

```mdx theme={null}
{/* in snippets/enterprise/pages/.../foo.mdx */} Go to
**{props.uiPath_uservpn_gateways}** to configure your gateway.
```

```json theme={null}
// in navigation-parser/version-props.json
{
  "8.2": { "uiPath_uservpn_gateways": "Cloud Fabric > UserVPN > select the VPN Gateways tab > click the Edit icon next to the gateway", ... },
  "9.0": { "uiPath_uservpn_gateways": "Cloud Fabric > UserVPN > VPN Gateways > select the gateway > Settings", ... }
}
```

`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.

```mdx theme={null}
{props.showWarningBanner && <Warning>This step requires admin
privileges.</Warning>}
```

```json theme={null}
{
  "8.2": { "showWarningBanner": false, ... },
  "9.0": { "showWarningBanner": true, ... }
}
```

**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:

```
snippets/enterprise/pages/guides/uservpn/duo-authentication.mdx        default for every version except 8.2
snippets/enterprise/pages/guides/uservpn/duo-authentication-v82.mdx    8.2 override
```

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:
   ```json theme={null}
   {
     "insert_after": "/docs/enterprise/{VERSION}/<section>/<...>/sibling-page",
     "pages": ["/docs/enterprise/<ver>/<section>/<...>/foo"]
   }
   ```
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`:

```json theme={null}
[
  ...existing entries...,
  {
    "remove": [
      "/docs/enterprise/9.0/<section>/<...>/foo"
    ]
  }
]
```

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

```bash theme={null}
make generate      # snippets → shells + shared+overrides → generated nav
make all           # generate + merge into docs.json
make dev           # all + mintlify dev (preview at http://localhost:3000)
make clean         # remove docs.json + generated/ + shells
```

`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:

```bash theme={null}
bash -c "ulimit -n 65536 && npx mintlify dev"
```

***

## 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)

| Decision | Rationale |
| - | - |
| Snippets as source of truth | Cross-version edits in one place; no copy-paste between version directories. |
| Build-time shell generation (not runtime) | Mintlify sees ordinary MDX pages, no special tooling needed. |
| `props.version` for internal doc links | A page's URL is per-version; the link target must follow. JSX expression evaluates at render time. |
| Per-version snippet suffix `-v82` / `-v90` (dot-free) | Mintlify silently fails to load imported snippets whose basename contains `.` before `.mdx`. Suffix = `v` + version with dots stripped. |
| Divergent images via `-8.2.<ext>` / `-9.0.<ext>` (dots OK here) | Mintlify serves `/images/` files via Next.js static-asset path; dots are fine there. The runtime template `src={"/images/.../-" + props.version + ".png"}` picks the right file per version. |
| Audit trail derived from git, not stored in JSON | Earlier designs put `reason`/`removed_by`/`date` fields in the override `remove` entries. That created two problems: (a) the fields could be fabricated — and were, in commit `ac9ab529` — because nothing tied the JSON value to a real human decision; (b) writers had to maintain the audit log by hand, which drifted from the actual change history. Current design: override entries are just paths. The commit that introduces each entry IS the audit record (author, date, message). `scripts/removals-log.py` reconstructs the human-readable log on demand by walking `git log -p` for the override files. Git is the only place audit trails can't be forged. |
| 8.2 nav `nav_managed: true` | After migration, 8.2's nav is also generated. The diff against the pre-migration hand-authored `enterprise-8.2.json` was verified byte-equivalent (modulo formatting) before the flip. |
| Older versions (7.x/8.0/8.1) stay hand-authored under `docs/enterprise/older-versions/` | Out of migration scope. Those versions are frozen and don't see new content. |

***

## See also

* section 8 of `CONTRIBUTING.md` — day-to-day editing instructions
* `README.md` — repo layout, build system, and content-management overview


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.