Feature Table Audit
The feature comparison table (rendered by the FeatureTable component from src/data/features.yaml) drives three things in the docs, each its own audit facet:
- Sidebar tier badges (FREE / ENTERPRISE pills) that must match plan assignments in
~/git/vcluster/plans/. - Feature
docs_urllink targets that each row links to. <FeatureTable names="...">row imports that decide which rows a given page surfaces.
This skill covers all three. Labels and the plans repo come first, then the link-target and row-import audits.
The feature table source (src/data/features.yaml)
src/data/features.yaml is the single source the FeatureTable component reads. Each entry is keyed by a feature ID and carries the row label, description, category, optional pricing, and docs_url:
ha-mode:
name: "High-Availability Mode" # label shown in the table
description: "..."
category: "Deployment Modes"
docs_url: "/docs/platform/maintenance/reliability-scaling/high-availability"
pricing: "add-on" # optional
Feature keys are synced from loft-sh/admin-apis, and tiers from loft-sh/plans (see the header comment in the file), so do not hand-edit keys. The docs_url value is the docs-owned lever you audit, and it drives both the link target (facet 2) and the canonical FeatureTable placement (facet 3).
Critical: docs_url is the source of truth for where a feature's <FeatureTable> lives. scripts/sync-feature-tables.js maps each feature's docs_url (anchor stripped) to a file and inserts/updates the <FeatureTable> on that page. npm run validate-feature-tables runs it with --dry-run and the validate-feature-tables CI check fails if anything is out of sync. So after editing any docs_url, run npm run sync-feature-tables and commit the regenerated pages, or CI will fail.
Sidebar tier badges
Sidebar badges in the docs are driven by CSS classes set via frontmatter or category config. They must match the feature assignments in ~/git/vcluster/plans/.
How the Labels Work
Setting a label
On an .mdx page (frontmatter):
sidebar_class_name: pro # ENTERPRISE badge
sidebar_class_name: free # FREE badge
sidebar_class_name: pro host-nodes # multiple classes — only one label class allowed
On a category (_category_.json):
{ "className": "pro" }
{ "className": "free" }
The category className renders on the collapsible section header only — it does not visually propagate to pages inside the category (no CSS or theme code copies it onto child <li> items). That's a rendering fact, not a rule to work around by duplicating the badge onto every child page.
When to badge the category vs. individual pages:
- Every page (and sub-category) under a category shares the same tier → badge the category only, in
_category_.json. Don't also addsidebar_class_nameto each child page — that's redundant duplication, not a fix for a "propagation bug." A reader who expands the category already sees the tier on the header. - Pages under a category carry different tiers → don't badge the category at all. Give each child page its own
sidebar_class_nameinstead.
Never do both (a category badge plus matching per-page badges on every child) — that's redundant, and it's the mistake this skill used to make (PR #2648 added per-page pro badges to all 10 platform/maintenance/observability/ pages under a category already badged pro; corrected in #2603 by removing the per-page badges instead of updating all 11 in lockstep).
What the badges render
Defined in src/css/sidebar.scss:
.pro→ orange "ENTERPRISE" pill.free→ dark "FREE" pill
The Plans Repo
Plans live at ~/git/vcluster/plans/:
| File | Tier |
|---|---|
free.yaml |
Free |
dev.yaml |
Dev (superset of free) |
prod.yaml |
Prod (superset of dev) |
scale.yaml |
Scale/Enterprise (superset of prod) |
internal.yaml |
Internal (superset of all) |
Rule: A feature in free.yaml → label free. A feature absent from free.yaml but present in any paid plan → label pro.
Finding the Feature Key
The feature key is exposed on the page via the FeatureTable component:
<FeatureTable names="vault-integration" />
<FeatureTable names="ha-mode,regional-cluster-endpoints" />
The names attribute is the feature key to look up in the plans YAML files.
Pages with <ProAdmonition/> but no FeatureTable require checking the code (see below).
Audit Workflow
1. Find all labeled pages
# Pages with sidebar_class_name
grep -rn "sidebar_class_name.*pro\|sidebar_class_name.*free" platform vcluster --include="*.mdx"
# Categories with className
grep -rn "\"className\"" platform vcluster --include="_category_.json"
2. Find pages with feature references but no label (missing labels)
# Pages with FeatureTable or ProAdmonition but no sidebar_class_name
grep -rln "FeatureTable\|ProAdmonition" platform vcluster --include="*.mdx" \
| grep -v "_partials\|_fragments\|versioned_docs" \
| while read f; do
grep -q "sidebar_class_name" "$f" || echo "UNLABELED: $f"
done
Before treating a hit as a real gap, check whether its parent directory's _category_.json already carries a className — if so, and every sibling page under that category shares the same tier, the category badge already covers it and no per-page sidebar_class_name is needed (see the category-vs-page rule above).
3. Extract feature keys from unlabeled pages
grep -o 'names="[^"]*"' <file.mdx>
4. Check the plan tier
grep "<feature-key>" ~/git/vcluster/plans/free.yaml # in free = label free
grep "<feature-key>" ~/git/vcluster/plans/dev.yaml # not in free but in dev+ = label pro
If the feature key appears in free.yaml → free. If absent from free.yaml → pro.
5. Apply the label
Add sidebar_class_name: pro (or free) as the last field before the closing --- in the frontmatter.
Verifying ProAdmonition Usage
<ProAdmonition/> shows an "Enterprise-Only Feature" callout inline. It should only appear when the surrounding content is genuinely enterprise-gated.
Common issues:
- Imported but never used — dead import, just remove the
importline. - Used in a sub-section of a free feature — remove the admonition from that section.
- Used for a feature with no plan key — check the vCluster OSS/Pro Go code for a license gate. Search in
~/git/vcluster/vcluster-pro/for the feature name nearlicense.GetLicense()orcurrentLicense.Enabled(...).
Skip These
- Air-gapped install pages inside a
pro-labeled category — the category header carries the badge; individual pages don't need one. - API reference pages — no label needed.
- Overview/index pages that span multiple tiers — no label needed.
vcluster/introduction/oss-vs-free.mdx— intro page.
What to Check in Code
When a ProAdmonition has no matching FeatureTable, verify the gate in the Go code:
# Check if feature is in OSS or Pro repo
grep -r "migrate\|<feature-name>" ~/git/vcluster/vcluster-pro/pkg/ | grep -i "license\|Enabled\|feature"
# OSS stub pattern confirms pro-only:
# var Foo = func(...) { return nil, NewFeatureError(...) }
grep -r "NewFeatureError" ~/git/vcluster/vcluster/pkg/pro/
If the implementation lives only in vcluster-pro/ with a license check → the ProAdmonition is correct and the page needs sidebar_class_name: pro.
Audit: docs_url link targets
Every docs_url in features.yaml should resolve to a real page whose topic matches the feature.
URL to file mapping (site baseUrl is /docs):
/docs/vcluster/PATHmaps tovcluster/PATH.mdxorvcluster/PATH/README.mdx/docs/platform/PATHmaps toplatform/PATH.mdxorplatform/PATH/README.mdx
Three gotchas that make a link look fine but break:
slug:overrides. A page can setslug:in frontmatter, so the file path is not the served URL. If the literal path file is missing, grep for aslug:that resolves to the URL before calling the link broken (this is howair-gappedandstandaloneresolve).- Anchors.
#foomust match a heading (slugified: lowercase, spaces to hyphens) or an explicit{#id}. A valid page with a dead anchor still loads, it just lands at the top. - Moved pages. A page relocating under a new parent (for example into
configure/platform-configs/) silently breaks the olddocs_url; nothing redirects.
Existence, slug, and anchor checks:
ls vcluster/PATH.mdx platform/PATH.mdx 2>/dev/null # literal path
grep -rl "^slug:.*PATH" platform vcluster --include="*.mdx" # slug fallback
grep -n "{#anchor-id}\|^#\+ Heading Text" path/to/page.mdx # anchor
Orphaned partials. Some config reference partials (for example _partials/config/experimental/genericSync.mdx) carry valid {#id} anchors but are imported into no page, so a docs_url pointing at that anchor never resolves. Confirm something imports the partial:
grep -rln "experimental/genericSync" platform vcluster --include="*.mdx" | grep -v versioned
If nothing imports it, the fix is to create the page (mirroring a sibling page that imports its partial), not to tweak the link.
Audit: <FeatureTable names="..."> row imports
A page's <FeatureTable> renders the row(s) named in names. There are two kinds of placement, and they are governed differently:
Canonical placements are generated, not hand-audited. For every feature with a docs_url, sync-feature-tables.js ensures the page that docs_url resolves to contains a <FeatureTable> listing that feature (multiple features sharing a docs_url are merged into one names="a,b" table). You do not place these by hand. To move or fix a canonical table, change the feature's docs_url and run npm run sync-feature-tables. To preview, run npm run validate-feature-tables (the same --dry-run the CI runs); it prints [INSERT] / [UPDATE] for anything stale and exits non-zero.
The script only manages pages that are some feature's docs_url target. It does not remove <FeatureTable> from other pages, and resolves docs_url only by literal file path; targets that work via slug: or a directory README.mdx (e.g. standalone → binary/, argo-integration, air-gapped-mode) log [WARN] No file found and are skipped, not failed. External URLs (vnode) are skipped too.
Extra placements are the manual part. A page may also surface a row it merely discusses, even though it is not that feature's docs_url target (e.g. the vcluster sleep page showing vcp-distro-sleep-mode). The generator leaves these alone and CI does not check them, so they need human judgment:
grep -rn "<FeatureTable" platform vcluster --include="*.mdx" | grep -v versioned
(names="all" on oss-vs-free.mdx and what-are-virtual-clusters.mdx is intentional and renders the whole table.)
For each, check the named row against the page topic. The classic bug is a wrong row — the HA page once imported platform-external-db instead of ha-mode, and a deletion page imported the Auto Sleep row. Fix by correcting names or dropping the table.
The invariant: a page may surface any row it genuinely discusses, but every row must have one sensible canonical docs_url, and the canonical table placement follows from that docs_url automatically.