diff --git a/scripts/versioning/README.md b/scripts/versioning/README.md index fab041f3..6db9fc3c 100644 --- a/scripts/versioning/README.md +++ b/scripts/versioning/README.md @@ -1,579 +1,244 @@ # Documentation Versioning System -This directory contains the unified versioning system for Cosmos documentation across multiple products (EVM, SDK, IBC). It automates the process of freezing documentation versions while maintaining product‑specific assets (e.g., EVM EIP compatibility tables). - -## Overview - -The versioning system provides: - -- **Version snapshots** - Frozen versions preserve the state of documentation at release time -- **Google Sheets integration (EVM only)** - EIP compatibility data is snapshotted via Google Sheets tabs -- **Automated workflow** - Single command to freeze current version and prepare for next release -- **Mintlify compatibility** - Works within Mintlify's MDX compiler constraints - -## Architecture - -### Version Structure - -```sh -docs/ -├── evm/ -│ ├── next/ # Working directory (always contains latest docs) -│ │ ├── documentation/ # This is where active development happens -│ │ ├── api-reference/ -│ │ └── changelog/ -│ ├── v0.4.x/ # Frozen snapshot (copied from next/) -│ │ ├── .version-frozen # Marker file -│ │ ├── .version-metadata.json -│ │ ├── documentation/ # Snapshot from evm/next/ -│ │ ├── api-reference/ -│ │ └── changelog/ -│ └── v0.5.0/ # Another frozen snapshot -│ └── ... -├── sdk/ -│ ├── next/ # SDK working directory -│ ├── v0.53/ # SDK frozen snapshots -│ ├── v0.50/ -│ └── v0.47/ -└── ibc/ - └── next/ # IBC working directory -``` - -#### The "next" Directory Workflow - -The `next` directory is the **working directory** for active documentation development: +Unified versioning scripts for the Cosmos documentation site (Mintlify). Covers all products: `sdk`, `ibc`, `evm`, `cometbft`, `hub`, etc. -1. **Development**: All documentation updates happen in `docs//next/` -2. **Freezing**: When ready to release, the `next` directory is **copied** to `docs///` -3. **Preservation**: The original `next` directory **remains unchanged** and continues to be the working directory -4. **Links Updated**: Only the frozen copy has its internal links updated to point to the versioned path -5. **Continued Work**: After freezing, development continues in `next/` for the upcoming release +## Model: next → latest → archive -**Example workflow:** +Every product has three directory tiers: -```bash -# Before freeze: Working on v0.5.0 in evm/next/ -# Run freeze with version v0.5.0 -npm run freeze -# After freeze: -# - evm/v0.5.0/ created (frozen snapshot with updated links) -# - evm/next/ unchanged (continues as working directory for v0.6.0) -``` +| Directory | Purpose | SEO | +| --------- | ------- | --- | +| `/next/` | Active development — unreleased changes | `noindex` (add manually to MDX front matter) | +| `/latest/` | Current stable release — accumulates Google ranking | Indexed, canonical | +| `/v0.53/` etc. | Archived releases — preserved for reference | `noindex: true` + `canonical:` pointing at `latest/` | -### Navigation Structure +**Why `latest/`?** A stable URL means SEO equity is never lost on a version bump. When you ship `v0.54`, the URL `/sdk/latest/` stays the same — only the content changes. Archived pages redirect search engines to the equivalent page in `latest/`. -Docs now use product-specific dropdowns with per-product versions: +### Directory layout -```json -{ - "navigation": { - "dropdowns": [ - { - "dropdown": "EVM", - "versions": [ - { - "version": "next", - "tabs": [ - /* evm/next/... */ - ] - }, - { - "version": "v0.4.x", - "tabs": [ - /* evm/v0.4.x/... */ - ] - } - ] - }, - { - "dropdown": "SDK", - "versions": [ - { - "version": "v0.53", - "tabs": [ - /* sdk/v0.53/... */ - ] - } - ] - } - ] - } -} +```text +sdk/ +├── next/ ← active development (pre-release) +├── latest/ ← current stable (SEO-indexed) +├── v0.53/ ← archived (noindex + canonical → latest) +├── v0.50/ ← archived +└── v0.47/ ← archived ``` -### Versions Registry +--- -The top-level `versions.json` tracks versions per product (subdirectory under `docs/`). Each product configuration includes: +## Version freeze process (end-of-release runbook) -- **versions**: Array of available versions (filesystem is auto-discovered and merged) -- **defaultVersion**: The default version shown to users (usually "next") -- **repository**: GitHub repository path for changelog fetching (e.g., "cosmos/evm") -- **changelogPath**: Path to changelog file in the repository (default: "CHANGELOG.md") -- **nextDev**: (Optional) Advisory label for the next development version +When a new release is ready to ship, run through these steps in order. -Example: +### 1. Merge all in-progress `next/` content -```json -{ - "products": { - "evm": { - "versions": ["next", "v0.4.x"], - "defaultVersion": "next", - "nextDev": "v0.5.0", - "repository": "cosmos/evm", - "changelogPath": "CHANGELOG.md" - }, - "sdk": { - "versions": ["next", "v0.53", "v0.50", "v0.47"], - "defaultVersion": "next", - "repository": "cosmos/cosmos-sdk", - "changelogPath": "CHANGELOG.md" - }, - "ibc": { - "versions": ["next"], - "defaultVersion": "next", - "repository": "cosmos/ibc-go", - "changelogPath": "CHANGELOG.md" - } - } -} -``` - -#### Auto-Discovery - -The system automatically discovers products and versions at runtime: - -1. **Product Discovery**: Scans `./docs/` directory for subdirectories (evm, sdk, ibc, etc.) -2. **Version Discovery**: Scans each product directory for version folders (next, v0.4.x, v0.53, etc.) -3. **Intelligent Merging**: Merges discovered versions with configured versions in `versions.json` -4. **Default Configuration**: Creates sensible defaults for new products not yet in `versions.json` - -This means: - -- New products at repo root are automatically recognized -- Manually created version directories are automatically tracked -- The `versions.json` file is the source of truth for repository configuration -- Version arrays are kept in sync with the filesystem - -## Quick Start +Make sure `next/` contains everything that belongs in the new release. Merge any open PRs targeting `next/`. -### Prerequisites - -1. **Google Sheets API Access** - - - Service account key saved as `service-account-key.json` - - See [GSHEET-SETUP.md](https://github.com/cosmos/docs/blob/main/scripts/versioning/GSHEET-SETUP.md) for detailed setup - -2. **Install Dependencies** - - ```bash - cd scripts/versioning - npm install - ``` - -### Freeze a Version - -Run the version manager to freeze the current version in a chosen docs subdirectory (e.g., `evm`, `sdk`, `ibc`) and start a new one. The flow is fully interactive by default: +### 2. Run the freeze script ```bash cd scripts/versioning npm run freeze ``` -The script will: - -1. Prompt for the product (based on folders under `docs/`) -2. Show the product’s entry from `versions.json` (versions, defaultVersion, nextDev) -3. Prompt for the freeze version (e.g., `v0.5.0`, `v0.5.x`) -4. Prompt for the new development version -5. Check/update release notes for that product; if missing, auto‑fetch from GitHub -6. Create a frozen copy at `//` -7. Snapshot EIP data to a Google Sheets tab and generate versioned EIP reference (EVM only) -8. Update navigation (clone `next` entry to ``) and versions registry (per‑product) -9. Create metadata files +The script will prompt for: -Non‑interactive: +- **Product** — e.g. `sdk`, `ibc` +- **New display version** — the label for the outgoing `latest/` when it becomes an archive (e.g. `v0.54`) -```bash -NON_INTERACTIVE=1 \ - SUBDIR=evm \ - CURRENT_VERSION=v0.5.0 \ - NEW_VERSION=v0.6.0 \ - npm run freeze -``` +**What happens internally:** -## Scripts Reference +1. Reads `versions.json` to find the current `latestDisplayVersion` (e.g. `v0.53`) +2. Copies `latest/` → `v0.53/` (archive) +3. Rewrites internal links in the archive: `/sdk/latest/` → `/sdk/v0.53/` +4. Injects `noindex: true` + `canonical:` into every MDX file in the archive +5. Copies `next/` → `latest/` (promote) +6. Rewrites internal links in the new `latest/`: `/sdk/next/` → `/sdk/latest/` +7. Clones the `latest` nav entry in `docs.json` to create a new `v0.53` nav entry +8. Updates the `latest` nav badge to reflect the new display version (`v0.54`) +9. Updates `versions.json` with new `latestDisplayVersion` -### Core Scripts (ESM) - -#### `version-manager.js` - -Main orchestration script for complete version freezing workflow. - -**Usage:** +**Non-interactive:** ```bash -npm run freeze +NON_INTERACTIVE=1 SUBDIR=sdk NEW_DISPLAY_VERSION=v0.54 npm run freeze ``` -**What it does:** - -- Creates frozen copy of `/next/` at `//` -- Calls sheets-manager for Google Sheets operations (EVM only) -- Updates all internal links in frozen version -- Updates navigation structure and version registry -- Creates version metadata files - -#### `sheets-manager.js` - -Google Sheets operations for EIP data versioning. - -**Usage:** +### 3. Verify locally ```bash -npm run sheets +npx mint dev ``` -**What it does:** - -- Creates version-specific tab in Google Sheets -- Copies data from main sheet to version tab -- Generates EIP reference MDX with sheetTab prop -- Handles authentication and error recovery +Check that `/sdk/latest/` loads correctly and `/sdk/v0.53/` shows the archived notice (if any). -#### `manage-changelogs.js` - -Unified changelog management for all products. Handles version-specific filtering, automatic generation, and integration with the versioning workflow. - -**Usage:** +### 4. Commit and push ```bash -# Generate changelog for 'next' (all versions) -npm run changelogs -- --product evm --target next - -# Generate changelog for specific version (e.g., v0.5.x releases for v0.5.0 directory) -npm run changelogs -- --product evm --target v0.5.0 - -# Generate all changelogs for a product -npm run changelogs -- --product evm --all - -# Test generation without modifying files (output to ./tmp) -npm run changelogs -- --product evm --all --staging - -# Called by versioning script during version freeze -npm run changelogs -- --product evm --target v0.5.0 --freeze +git add -A +git commit -m "docs: freeze sdk v0.53, promote next to latest (v0.54)" +git push ``` -**Command-Line Options:** - -- `--product ` - Product name (evm, sdk, ibc, hub) [default: evm] -- `--target ` - Target version directory (next, v0.5.0, v0.4.x, etc.) -- `--filter ` - Version filter pattern (v0.5, v0.4, etc.) - auto-detected from target if not specified -- `--all` - Generate changelogs for all versions of the product -- `--freeze` - Flag indicating this is a version freeze operation -- `--source ` - Git ref to fetch from (main, tag, etc.) [default: main] -- `--staging` - Output to ./tmp directory instead of actual locations for testing - -**What it does:** - -- Fetches changelog from the product's GitHub repository (tries multiple paths: `CHANGELOG.md`, `RELEASE_NOTES.md`, etc.) -- Parses changelog and filters by version pattern when targeting versioned directories -- Converts to Mintlify format with `` components -- Updates release notes file in `//changelog/release-notes.mdx` -- Auto-generates appropriate Info messages (versioned pages link to 'next', 'next' links to upstream UNRELEASED) - -**Version Filtering:** - -The script automatically filters versions based on the target directory: - -- `next` → Shows all versions from the changelog -- `v0.5.0` → Shows only v0.5.x versions (v0.5.0, v0.5.1, v0.5.2, etc.) -- `v0.4.x` → Shows only v0.4.x versions - -**Repository Sources:** - -Release notes are fetched from GitHub repositories configured in `versions.json`: - -- **evm** → `cosmos/evm` -- **sdk** → `cosmos/cosmos-sdk` -- **ibc** → `cosmos/ibc-go` -- **hub** → `cosmos/gaia` - -**Changelog Format Compatibility:** - -All repositories use similar changelog formats with minor variations: - -| Repository | Version Format | Example | -| ----------------- | -------------- | ------------- | -| cosmos/evm | `## v0.4.1` | No brackets | -| cosmos/cosmos-sdk | `## [v0.53.0]` | With brackets | -| cosmos/ibc-go | `## [v10.4.0]` | With brackets | -| cosmos/gaia | `## [v25.0.0]` | With brackets | - -The parser handles both formats automatically through flexible regex matching. Sections like "Features", "Bug Fixes", "Improvements" are recognized regardless of case variations. - -**Integration:** +--- -- Called by `version-manager.js` during version freeze to generate version-specific changelogs -- Triggered by GitHub Actions workflow when new releases are published -- Can be run manually to update existing changelogs or add new releases +## Retroactive archiving: `tag-archived.js` -### Supporting Scripts +If existing versioned directories predate the `latest/` model (e.g. `sdk/v0.50`, `sdk/v0.47`), run `tag-archived.js` to inject `noindex` + `canonical` front matter into those files. -#### `test-versioning.js` - -System testing and validation. - -**Usage:** +### Usage ```bash -npm run test -``` - -#### `restructure-navigation.js` - -Navigation structure cleanup utility. +# Tag a single version +node tag-archived.js --product sdk --version v0.50 -## Google Sheets Integration +# Tag all archived versions for a product +node tag-archived.js --product sdk --all -EIP compatibility data is versioned through Google Sheets tabs. See [GSHEET-SETUP.md](https://github.com/cosmos/docs/blob/main/scripts/versioning/GSHEET-SETUP.md) for setup and configuration. +# Tag all archived versions across all products +node tag-archived.js --all-products --all -### Shared Component +# Dry-run: see what would change without modifying files +node tag-archived.js --product sdk --all --dry-run -The `/snippets/eip-compatibility-table.jsx` component accepts a `sheetTab` prop: - -```jsx -export default function EIPCompatibilityTable({ sheetTab } = {}) { - const url = sheetTab - ? `https://docs.google.com/spreadsheets/d/${SHEET_ID}/gviz/tq?sheet=${sheetTab}&tqx=out:json` - : `https://docs.google.com/spreadsheets/d/${SHEET_ID}/gviz/tq?sheet=eip_compatibility_data&tqx=out:json`; - // ... -} +# Override the canonical base URL +node tag-archived.js --product sdk --all --base-url https://docs.cosmos.network ``` -### Version-Specific Usage - -Frozen versions use the component with their tab: - -```mdx - -``` - -Active development uses it without props (defaults to main sheet): - -```mdx - -``` - -## How It Works - -### Version Freeze Process - -1. **Preparation Phase** - - - Pick product subdirectory (`evm`, `sdk`, `ibc`) - - Determine current version to freeze from `versions.json` for that product (or prompt) - - Prompt for new development version - - Check/update release notes (auto‑fetch when missing) - -2. **Freeze Phase** - - - Copy `/next/` to version directory (e.g., `evm/v0.4.x/`) - - (EVM only) Create Google Sheets tab with version name and copy EIP data +### What it does -3. **Update Phase** +For each `.mdx` file in the targeted archive directory: - - (EVM only) Generate MDX with sheet tab reference - - Update internal links (`//next/` → `///`) - - Keep snippet imports unchanged (`/snippets/`) - - Update navigation structure +1. Skips files that already have `noindex:` in front matter +2. Checks whether the equivalent page exists in `/latest/` + - If yes → `canonical: 'https://docs.cosmos.network//latest/'` + - If no → `canonical: 'https://docs.cosmos.network//latest/'` (fallback to root) +3. Injects `noindex: true` and `canonical:` at the top of the front matter block +4. Writes the file in place -4. **Finalization Phase** - - Create `.version-frozen` marker - - Create `.version-metadata.json` - - Register per-product versions in `versions.json` - - Record next development version label per product +Run this once per product when first setting up the `latest/` model, then the freeze script handles archiving automatically going forward. -### Path Management +--- -The system handles three types of paths: - -1. **Document paths**: Updated to version-specific - - - Before: `/evm/next/documentation/concepts/accounts` - - After: `/evm/v0.4.x/documentation/concepts/accounts` - -2. **Snippet imports**: Remain unchanged (shared) - - - Always: `/snippets/icons.mdx` - -3. **External links**: Remain unchanged - - Always: `https://example.com` - -## Mintlify Constraints - -The system works within Mintlify's MDX compiler limitations: - -### What Works - -- Component imports from `/snippets/` -- Props passed to components -- Standard MDX syntax -- HTML comments for metadata - -### What Doesn't Work - -- Inline component definitions -- Dynamic imports -- JSON imports in MDX -- JavaScript expressions in MDX body -- Runtime code execution - -See [Mintlify Constraints](../../CLAUDE.md) for details. - -## Setup Guide - -### 1. Google Sheets API Setup - -Follow [GSHEET-SETUP.md](https://github.com/cosmos/docs/blob/main/scripts/versioning/GSHEET-SETUP.md) to: - -1. Create a Google Cloud project -2. Enable Sheets API -3. Create service account -4. Download credentials -5. Share spreadsheet with service account - -### 2. Install Dependencies +## Changelog management ```bash -cd scripts/versioning -npm install -``` +# Generate changelog for next (all versions) +npm run changelogs -- --product evm --target next -### 3. Configure Credentials +# Generate changelog for specific version directory +npm run changelogs -- --product evm --target v0.5.0 -Save your service account key as: +# Generate all changelogs for a product +npm run changelogs -- --product evm --all -```sh -scripts/versioning/service-account-key.json +# Test without modifying files (output to ./tmp) +npm run changelogs -- --product evm --all --staging ``` -### 4. Test Connection +Release notes are fetched from the GitHub repositories configured in `versions.json`: -```bash -node -e " -const { google } = require('./node_modules/googleapis'); -const fs = require('fs'); -const credentials = JSON.parse(fs.readFileSync('service-account-key.json')); -const auth = new google.auth.GoogleAuth({ - credentials, - scopes: ['https://www.googleapis.com/auth/spreadsheets'] -}); -console.log('✓ Google Sheets API configured'); -" -``` +| Product | Repository | +| ------- | ---------- | +| evm | `cosmos/evm` | +| sdk | `cosmos/cosmos-sdk` | +| ibc | `cosmos/ibc-go` | +| hub | `cosmos/gaia` | -## Usage Examples +--- -### Freeze Current Version +## versions.json -```bash -# Start the version freeze process -cd scripts/versioning && npm run freeze +The top-level `versions.json` tracks configuration per product. -# Enter prompts -Enter the docs subdirectory to version [evm, sdk, ibc]: evm -Enter the new development version (e.g., v0.5.0): v0.5.0 +```json +{ + "products": { + "sdk": { + "versions": ["next", "latest", "v0.53", "v0.50", "v0.47"], + "defaultVersion": "latest", + "latestDisplayVersion": "v0.53", + "repository": "cosmos/cosmos-sdk", + "changelogPath": "CHANGELOG.md" + } + } +} ``` -### Update Release Notes Only +Key fields: -```bash -# Generate changelog for evm/next (all versions) -cd scripts/versioning && npm run changelogs -- --product evm --target next +- **versions** — all available version directories (auto-discovered from filesystem and merged) +- **defaultVersion** — shown to users by default; should be `latest` once `latest/` exists +- **latestDisplayVersion** — the human-readable release label shown in the navigation badge (e.g. `v0.53 (Latest)`) +- **repository** — GitHub repo for changelog fetching +- **changelogPath** — path within the repo (default: `CHANGELOG.md`) -# Generate changelog for specific version directory -cd scripts/versioning && npm run changelogs -- --product evm --target v0.5.0 +--- -# Generate all changelogs for a product -cd scripts/versioning && npm run changelogs -- --product evm --all +## noindex convention for `next/` -# Test changelog generation without modifying files -cd scripts/versioning && npm run changelogs -- --product evm --all --staging -``` +The `next/` directory contains pre-release documentation. It is not blocked by the freeze script — you are responsible for adding `noindex: true` to pages in `next/` if you want to prevent search engines from indexing unreleased content. -### Manual Navigation Update +To add noindex to all files in a `next/` directory: ```bash -# If needed, manually update navigation for a version -npm run freeze # Full workflow includes navigation updates +node tag-archived.js --product sdk --version next --base-url https://docs.cosmos.network ``` -## Important Notes - -### Version Management - -- Development happens in `/next/` directory -- Frozen versions include `.version-frozen` marker -- Previous versions can be updated if needed - -### Version Naming - -- Use semantic versioning: `v0.4.0`, `v0.5.0` -- Special case: `v0.4.x` for minor version branches -- Active development is always `next` in navigation +> **Note:** This will set the canonical to `/sdk/latest/` for each page, which is correct — it tells Google the authoritative version is `latest/`. -### Google Sheets Management +--- -- Don't delete version tabs from spreadsheet -- Main sheet (`eip_compatibility_data`) is always live -- Version tabs are permanent snapshots +## Link rewriting -### Git Workflow +The freeze script uses Perl (not sed) for link replacement because Perl can skip external URLs while rewriting internal paths: -```bash -# After version freeze -git add -A -git commit -m "docs: freeze v0.4.x and begin v0.5.0 development" -git push +```perl +s{(https?://\S+)|/sdk/next/}{defined($1)?$1:"/sdk/latest/"}ge ``` -## Maintenance +This pattern: -### Cleaning Up Test Versions +1. Matches a full `https://` or `http://` URL → returns it unchanged +2. Otherwise matches the internal path prefix → replaces it -If you need to remove a test version: +This prevents GitHub links like `https://github.com/cosmos/cosmos-sdk/blob/release/v0.53.x/...` from being accidentally rewritten. -```bash -# Remove frozen directory -rm -rf /v0.5.0/ +--- -# Update per-product entries in versions.json or re-run version manager +## Scripts reference -# Remove navigation entry (manual edit of docs.json) -# Remove Google Sheets tab (manual via Google Sheets UI) -``` +| Script | Command | Purpose | +| ------ | ------- | ------- | +| `version-manager.js` | `npm run freeze` | Full version freeze workflow | +| `tag-archived.js` | `node tag-archived.js` | Inject noindex/canonical into archived dirs | +| `manage-changelogs.js` | `npm run changelogs` | Fetch and update release notes | +| `test-versioning.js` | `npm run test` | System validation | -## File Structure +--- -```sh -scripts/versioning/ -├── README.md # This file -├── GSHEET-SETUP.md # Google Sheets API setup guide -├── version-manager.js # Main orchestration (ESM) -├── sheets-manager.js # Google Sheets operations (ESM) -├── manage-changelogs.js # Unified changelog management (ESM) -├── test-versioning.js # System testing (ESM) -├── restructure-navigation.js # Navigation cleanup utility -├── package.json # Node dependencies with ESM support -├── package-lock.json # Dependency lock file -└── service-account-key.json # Google service account (git-ignored) -``` +## File structure -## Related Documentation - -- [Main README](../../README.md) - Project overview -- [CLAUDE.md](../../CLAUDE.md) - AI assistant context -- [GSHEET-SETUP.md](https://github.com/cosmos/docs/blob/main/scripts/versioning/GSHEET-SETUP.md) - Google Sheets API setup -- [SECURITY-SYNC.md](./SECURITY-SYNC.md) - Security documentation sync system -- [Mintlify Documentation](https://mintlify.com/docs) - MDX reference +```text +scripts/versioning/ +├── README.md # This file +├── GSHEET-SETUP.md # Google Sheets API setup (EVM only) +├── SECURITY-SYNC.md # Security docs sync system +├── version-manager.js # Main freeze orchestration +├── tag-archived.js # Retroactive noindex/canonical injection +├── manage-changelogs.js # Unified changelog management +├── sheets-manager.js # Google Sheets operations (EVM only) +├── test-versioning.js # System testing +├── restructure-navigation.js # Navigation cleanup utility +├── package.json +└── service-account-key.json # Google service account (git-ignored) +``` + +--- + +## Related + +- [CLAUDE.md](../../CLAUDE.md) — AI assistant context and Mintlify constraints +- [GSHEET-SETUP.md](./GSHEET-SETUP.md) — Google Sheets API setup for EVM EIP tables +- [Mintlify docs](https://mintlify.com/docs) — MDX reference diff --git a/scripts/versioning/tag-archived.js b/scripts/versioning/tag-archived.js new file mode 100644 index 00000000..ee59d192 --- /dev/null +++ b/scripts/versioning/tag-archived.js @@ -0,0 +1,255 @@ +#!/usr/bin/env node + +/** + * tag-archived.js + * + * Retroactively injects `noindex: true` and `canonical` front matter into + * existing archived (versioned) documentation directories. + * + * This is a one-time or per-product maintenance tool. Run it whenever: + * - A product's existing versioned directories (e.g. sdk/v0.50, sdk/v0.47) + * need to be marked as non-indexable by search engines. + * - You want to point archived pages at the canonical /latest/ equivalent. + * + * Usage: + * node tag-archived.js --product sdk --version v0.50 + * node tag-archived.js --product sdk --all + * node tag-archived.js --product ibc --version v8.5.x + * node tag-archived.js --all-products --all + * + * Options: + * --product Product name (sdk, ibc, evm, cometbft, hub, etc.) + * --version Single version directory to tag (e.g. v0.53) + * --all Tag all non-latest, non-next versioned dirs for the product + * --all-products Run across every product discovered in the repo + * --dry-run Print what would change without modifying files + * --base-url Override canonical base URL (default: https://docs.cosmos.network) + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.join(__dirname, '..', '..'); +const BASE_URL = 'https://docs.cosmos.network'; + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- + +function parseArgs(argv) { + const args = { product: null, version: null, all: false, allProducts: false, dryRun: false, baseUrl: BASE_URL }; + for (let i = 0; i < argv.length; i++) { + switch (argv[i]) { + case '--product': args.product = argv[++i]; break; + case '--version': args.version = argv[++i]; break; + case '--all': args.all = true; break; + case '--all-products':args.allProducts = true; break; + case '--dry-run': args.dryRun = true; break; + case '--base-url': args.baseUrl = argv[++i]; break; + } + } + return args; +} + +// --------------------------------------------------------------------------- +// Output helpers +// --------------------------------------------------------------------------- + +const colors = { red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', reset: '\x1b[0m' }; +function info(msg) { console.log(`${colors.blue}ℹ${colors.reset} ${msg}`); } +function success(msg) { console.log(`${colors.green}✓${colors.reset} ${msg}`); } +function warn(msg) { console.log(`${colors.yellow}⚠${colors.reset} ${msg}`); } +function skip(msg) { console.log(` ${msg}`); } + +// --------------------------------------------------------------------------- +// Product / version discovery +// --------------------------------------------------------------------------- + +function discoverProducts() { + const ignore = new Set(['node_modules', 'scripts', 'snippets', 'assets', '.git']); + return fs.readdirSync(REPO_ROOT, { withFileTypes: true }) + .filter(d => d.isDirectory() && !d.name.startsWith('.') && !ignore.has(d.name)) + .map(d => d.name) + .filter(name => { + const entries = fs.readdirSync(path.join(REPO_ROOT, name), { withFileTypes: true }); + return entries.some(d => d.isDirectory() && /^v\d+/.test(d.name)); + }); +} + +function discoverArchivedVersions(product) { + const productDir = path.join(REPO_ROOT, product); + if (!fs.existsSync(productDir)) return []; + return fs.readdirSync(productDir, { withFileTypes: true }) + .filter(d => d.isDirectory() && /^v\d+/.test(d.name)) + .map(d => d.name); +} + +// --------------------------------------------------------------------------- +// Front matter injection +// --------------------------------------------------------------------------- + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/; + +/** + * Returns the relative page path within a product+version dir. + * e.g. sdk/v0.50/learn/concepts/accounts.mdx → learn/concepts/accounts + */ +function relativePagePath(filePath, productDir) { + const rel = path.relative(productDir, filePath); + return rel.replace(/\.mdx$/, ''); +} + +/** + * Determines the canonical URL for an archived page. + * + * Prefers a specific page URL if the equivalent page exists in latest/. + * Falls back to the product's /latest/ root if not found. + */ +function canonicalUrl(filePath, product, baseUrl) { + const productDir = path.join(REPO_ROOT, product); + const latestDir = path.join(productDir, 'latest'); + const relPage = relativePagePath(filePath, path.dirname(path.dirname(filePath))); // strip product/version prefix + + // Strip the version segment from the path to get the page-relative path + // filePath: /repo/sdk/v0.50/learn/concepts/accounts.mdx + // We want: learn/concepts/accounts.mdx + const versionDir = path.dirname(path.dirname(filePath)) === productDir + ? path.dirname(filePath) // single-level + : filePath; + + // Re-derive: get path relative to the version directory + const versionDirPath = filePath.split(path.sep).slice(0, -1).join(path.sep); // parent dir + // Walk up to find the version dir (first dir matching /^v\d+/) + const parts = path.relative(REPO_ROOT, filePath).split(path.sep); + // parts: ['sdk', 'v0.50', 'learn', 'concepts', 'accounts.mdx'] + const pageRelative = parts.slice(2).join(path.sep); // ['learn', 'concepts', 'accounts.mdx'] + const pageRelativeNoExt = pageRelative.replace(/\.mdx$/, ''); + + const latestEquivalent = path.join(latestDir, pageRelative); + if (fs.existsSync(latestEquivalent)) { + return `${baseUrl}/${product}/latest/${pageRelativeNoExt}`; + } + return `${baseUrl}/${product}/latest/`; +} + +/** + * Injects `noindex: true` and `canonical: ` into MDX front matter. + * Skips files that already have noindex set. + * Returns true if the file was (or would be) modified. + */ +function injectFrontMatter(filePath, product, baseUrl, dryRun) { + const raw = fs.readFileSync(filePath, 'utf8'); + const match = raw.match(FRONTMATTER_RE); + + if (match) { + const fm = match[1]; + if (/^noindex\s*:/m.test(fm)) { + return false; // already tagged + } + const canonical = canonicalUrl(filePath, product, baseUrl); + const newFm = `noindex: true\ncanonical: '${canonical}'\n${fm}`; + const newContent = raw.replace(FRONTMATTER_RE, `---\n${newFm}\n---`); + if (!dryRun) fs.writeFileSync(filePath, newContent, 'utf8'); + return true; + } else { + // No front matter — prepend one + const canonical = canonicalUrl(filePath, product, baseUrl); + const newContent = `---\nnoindex: true\ncanonical: '${canonical}'\n---\n\n${raw}`; + if (!dryRun) fs.writeFileSync(filePath, newContent, 'utf8'); + return true; + } +} + +// --------------------------------------------------------------------------- +// Walk and tag a version directory +// --------------------------------------------------------------------------- + +function walkMdx(dir, results = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walkMdx(full, results); + else if (entry.name.endsWith('.mdx')) results.push(full); + } + return results; +} + +function tagVersion(product, version, baseUrl, dryRun) { + const versionDir = path.join(REPO_ROOT, product, version); + if (!fs.existsSync(versionDir)) { + warn(`Directory not found: ${product}/${version} — skipping`); + return { tagged: 0, skipped: 0 }; + } + + const files = walkMdx(versionDir); + let tagged = 0, skipped = 0; + + for (const file of files) { + const modified = injectFrontMatter(file, product, baseUrl, dryRun); + if (modified) { + tagged++; + const rel = path.relative(REPO_ROOT, file); + if (dryRun) skip(`[dry-run] would tag: ${rel}`); + } else { + skipped++; + } + } + return { tagged, skipped }; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + if (!args.product && !args.allProducts) { + console.error('Error: --product or --all-products is required'); + console.error('Run with --help for usage.'); + process.exit(1); + } + + if (!args.version && !args.all) { + console.error('Error: --version or --all is required'); + process.exit(1); + } + + if (args.dryRun) info('Dry-run mode — no files will be modified.\n'); + + const products = args.allProducts ? discoverProducts() : [args.product]; + const baseUrl = args.baseUrl; + + let totalTagged = 0, totalSkipped = 0; + + for (const product of products) { + const productDir = path.join(REPO_ROOT, product); + if (!fs.existsSync(productDir)) { + warn(`Product directory not found: ${product} — skipping`); + continue; + } + + const versions = args.all ? discoverArchivedVersions(product) : [args.version]; + if (versions.length === 0) { + warn(`No archived versions found for ${product}`); + continue; + } + + info(`Product: ${product} | versions to tag: ${versions.join(', ')}`); + + for (const version of versions) { + const { tagged, skipped } = tagVersion(product, version, baseUrl, args.dryRun); + const label = args.dryRun ? '[dry-run] ' : ''; + success(`${label}${product}/${version}: tagged ${tagged} files, skipped ${skipped} already-tagged`); + totalTagged += tagged; + totalSkipped += skipped; + } + } + + console.log(''); + info(`Total: ${totalTagged} files tagged, ${totalSkipped} already had noindex.`); + if (args.dryRun) warn('Re-run without --dry-run to apply changes.'); +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/versioning/version-manager.js b/scripts/versioning/version-manager.js index 6256bf43..39b416b4 100644 --- a/scripts/versioning/version-manager.js +++ b/scripts/versioning/version-manager.js @@ -2,7 +2,19 @@ /** * Documentation Version Manager - * Main orchestration script for documentation version freezing + * + * Freezes documentation versions using a next → latest → archive model: + * + * 1. If latest/ exists: archive it as / (rewrite links, inject noindex/canonical) + * 2. Promote next/ → latest/ (rewrite links next → latest) + * 3. Update docs.json navigation and versions.json registry + * + * At release time the operator provides the new display version label (e.g. "v0.54"). + * The outgoing latest/ is archived using the label stored in versions.json latestDisplayVersion. + * + * Usage: + * npm run freeze # interactive + * NON_INTERACTIVE=1 SUBDIR=sdk NEW_DISPLAY_VERSION=v0.54 npm run freeze */ import fs from 'fs'; @@ -13,121 +25,82 @@ import { execSync } from 'child_process'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Color codes for output -const colors = { - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - reset: '\x1b[0m' -}; - -function printInfo(msg) { - console.log(`${colors.blue}${colors.reset} ${msg}`); -} +const BASE_URL = 'https://docs.cosmos.network'; -function printSuccess(msg) { - console.log(`${colors.green}✓${colors.reset} ${msg}`); -} +// --------------------------------------------------------------------------- +// Output helpers +// --------------------------------------------------------------------------- -function printWarning(msg) { - console.log(`${colors.yellow}${colors.reset} ${msg}`); -} +const colors = { red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', reset: '\x1b[0m' }; -function printError(msg) { - console.log(`${colors.red}✗${colors.reset} ${msg}`); -} +function printInfo(msg) { console.log(`${colors.blue}${colors.reset} ${msg}`); } +function printSuccess(msg) { console.log(`${colors.green}✓${colors.reset} ${msg}`); } +function printWarning(msg) { console.log(`${colors.yellow}${colors.reset} ${msg}`); } +function printError(msg) { console.log(`${colors.red}✗${colors.reset} ${msg}`); } async function prompt(question) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise(resolve => rl.question(question, answer => { rl.close(); resolve(answer); })); } +// --------------------------------------------------------------------------- +// Product / version discovery +// --------------------------------------------------------------------------- + function listDocsSubdirs() { const repoRoot = path.join(__dirname, '..', '..'); if (!fs.existsSync(repoRoot)) return []; - // List subdirectories at repo root that are products (evm, sdk, ibc, hub, etc.) - // Filter out non-product directories - const allDirs = fs.readdirSync(repoRoot, { withFileTypes: true }) - .filter(d => d.isDirectory()) + const ignore = new Set(['.', '..', 'node_modules', 'scripts', 'snippets', 'assets']); + return fs.readdirSync(repoRoot, { withFileTypes: true }) + .filter(d => d.isDirectory() && !d.name.startsWith('.') && !ignore.has(d.name)) .map(d => d.name) - .filter(name => !name.startsWith('.') && name !== 'node_modules' && name !== 'scripts' && name !== 'snippets' && name !== 'assets'); - - // Only return directories that have version subdirectories (next, v0.x.x, etc.) - return allDirs.filter(dirName => { - const dirPath = path.join(repoRoot, dirName); - const contents = fs.readdirSync(dirPath, { withFileTypes: true }); - return contents.some(d => d.isDirectory() && (d.name === 'next' || /^v\d+/.test(d.name))); - }); + .filter(name => { + const contents = fs.readdirSync(path.join(repoRoot, name), { withFileTypes: true }); + return contents.some(d => d.isDirectory() && (d.name === 'next' || d.name === 'latest' || /^v\d+/.test(d.name))); + }); } -// --- Versions registry helpers (per-product) --- +// --------------------------------------------------------------------------- +// Versions registry (versions.json) +// --------------------------------------------------------------------------- + function loadVersionsRegistry() { const versionsPath = path.join(__dirname, '..', '..', 'versions.json'); let data = {}; if (fs.existsSync(versionsPath)) { - try { - data = JSON.parse(fs.readFileSync(versionsPath, 'utf8')); - } catch (e) { - data = {}; - } - } - - // Ensure products object exists - if (!data.products || typeof data.products !== 'object') { - data = { products: {} }; + try { data = JSON.parse(fs.readFileSync(versionsPath, 'utf8')); } catch { data = {}; } } + if (!data.products || typeof data.products !== 'object') data = { products: {} }; - // Auto-discover products from repo root and merge with existing config + // Auto-discover products and versions from filesystem const subdirs = listDocsSubdirs(); for (const subdir of subdirs) { const base = path.join(__dirname, '..', '..', subdir); const entries = fs.readdirSync(base, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .map(d => d.name); + .filter(d => d.isDirectory()).map(d => d.name); - // Discover versions from filesystem - const discoveredVersions = []; - if (entries.includes('next')) discoveredVersions.push('next'); + const discovered = []; + if (entries.includes('next')) discovered.push('next'); + if (entries.includes('latest')) discovered.push('latest'); for (const name of entries) { - if (/^v\d+\.\d+(?:\.(?:\d+|x))?$/.test(name)) { - discoveredVersions.push(name); - } + if (/^v\d+\.\d+(?:\.(?:\d+|x))?$/.test(name)) discovered.push(name); } - // Merge with existing product config or create new if (!data.products[subdir]) { - // New product - create default config data.products[subdir] = { - versions: discoveredVersions, - defaultVersion: entries.includes('next') ? 'next' : discoveredVersions[0] || 'next', + versions: discovered, + defaultVersion: entries.includes('latest') ? 'latest' : (entries.includes('next') ? 'next' : discovered[0] || 'next'), repository: `cosmos/${subdir}`, changelogPath: 'CHANGELOG.md' }; } else { - // Existing product - merge discovered versions with configured ones - const existingVersions = data.products[subdir].versions || []; - const mergedVersions = Array.from(new Set([...discoveredVersions, ...existingVersions])); - - // Ensure 'next' is always first if it exists - const hasNext = mergedVersions.includes('next'); - const otherVersions = mergedVersions.filter(v => v !== 'next').sort(compareVersionsDesc); - data.products[subdir].versions = hasNext ? ['next', ...otherVersions] : otherVersions; - - // Ensure defaultVersion is set - if (!data.products[subdir].defaultVersion) { - data.products[subdir].defaultVersion = hasNext ? 'next' : otherVersions[0] || 'next'; - } - - // Ensure repository is set - if (!data.products[subdir].repository) { - data.products[subdir].repository = `cosmos/${subdir}`; - } - - // Ensure changelogPath is set - if (!data.products[subdir].changelogPath) { - data.products[subdir].changelogPath = 'CHANGELOG.md'; - } + const existing = data.products[subdir].versions || []; + const merged = Array.from(new Set([...discovered, ...existing])); + const stable = merged.filter(v => /^v\d+\.\d+(?:\.(?:\d+|x))?$/.test(v)).sort(compareVersionsDesc); + const special = ['next', 'latest'].filter(s => merged.includes(s)); + data.products[subdir].versions = [...special, ...stable]; + if (!data.products[subdir].repository) data.products[subdir].repository = `cosmos/${subdir}`; + if (!data.products[subdir].changelogPath) data.products[subdir].changelogPath = 'CHANGELOG.md'; } } @@ -138,20 +111,15 @@ function saveVersionsRegistry(registry, versionsPath) { fs.writeFileSync(versionsPath, JSON.stringify(registry, null, 2) + '\n'); } -// Accepts vX.Y, vX.Y.Z, or vX.Y.x function validateVersionFormat(version) { return /^v\d+\.\d+(?:\.(?:\d+|x))?$/.test(version); } function parseVersionTuple(v) { - // Returns [major, minor, patchNum, isX] const m = v.match(/^v(\d+)\.(\d+)(?:\.(\d+|x))?$/); if (!m) return null; - const major = parseInt(m[1], 10); - const minor = parseInt(m[2], 10); const patch = m[3] === undefined ? 0 : (m[3] === 'x' ? Number.POSITIVE_INFINITY : parseInt(m[3], 10)); - const isX = m[3] === 'x'; - return [major, minor, patch, isX]; + return [parseInt(m[1], 10), parseInt(m[2], 10), patch, m[3] === 'x']; } function compareVersionsDesc(a, b) { @@ -160,91 +128,251 @@ function compareVersionsDesc(a, b) { if (bt[0] !== at[0]) return bt[0] - at[0]; if (bt[1] !== at[1]) return bt[1] - at[1]; if (bt[2] !== at[2]) return bt[2] - at[2]; - // Prefer specific patch over x for the same major/minor - if (bt[3] !== at[3]) return (at[3] ? 1 : -1); - return 0; + return at[3] ? 1 : -1; } -function getCurrentVersion(subdir) { - try { - const envCurrent = process.env.CURRENT_VERSION || process.env.FREEZE_VERSION; - if (envCurrent) return envCurrent; - // No implicit selection to avoid overwriting an existing frozen version. - // Prompt the operator to specify the version to freeze. - return null; - } catch (error) { - return null; - } -} +// --------------------------------------------------------------------------- +// Release notes helpers +// --------------------------------------------------------------------------- function includesVersionInReleaseNotes(content, version) { if (!content || !version) return false; - const minorOnly = /^v\d+\.\d+$/.test(version); const tuple = parseVersionTuple(version); - if (!tuple) { - return content.includes(`"${version}"`); - } + if (!tuple) return content.includes(`"${version}"`); const [major, minor, patch, isX] = tuple; - let re; - if (isX) { - // Match any patch or x for same major.minor - re = new RegExp(`]*version="v?${major}\\.${minor}\\.(?:\\d+|x)"`); - } else if (minorOnly) { - // Provided only major.minor → accept optional patch - re = new RegExp(`]*version="v?${major}\\.${minor}(?:\\.\\d+)?"`); - } else { - // Exact version - re = new RegExp(`]*version="v?${major}\\.${minor}\\.${patch}"`); - } + const re = isX + ? new RegExp(`]*version="v?${major}\\.${minor}\\.(?:\\d+|x)"`) + : /^v\d+\.\d+$/.test(version) + ? new RegExp(`]*version="v?${major}\\.${minor}(?:\\.\\d+)?"`) + : new RegExp(`]*version="v?${major}\\.${minor}\\.${patch}"`); return re.test(content); } -async function checkReleaseNotes(currentVersion, subdir) { - const releaseNotesPath = path.join(__dirname, '..', '..', subdir, 'next', 'changelog', 'release-notes.mdx'); - if (!fs.existsSync(releaseNotesPath)) return false; - const content = fs.readFileSync(releaseNotesPath, 'utf8'); - return includesVersionInReleaseNotes(content, currentVersion); +async function checkReleaseNotes(version, subdir) { + // Check in both next/ and latest/ (whichever exists) + for (const dir of ['latest', 'next']) { + const p = path.join(__dirname, '..', '..', subdir, dir, 'changelog', 'release-notes.mdx'); + if (fs.existsSync(p)) { + return includesVersionInReleaseNotes(fs.readFileSync(p, 'utf8'), version); + } + } + return false; } -function updateVersionsRegistry({ subdir, freezeVersion, newVersion }) { - const { data, path: versionsPath } = loadVersionsRegistry(); - if (!data.products) data.products = {}; - if (!data.products[subdir]) data.products[subdir] = { versions: [], defaultVersion: 'next' }; +// --------------------------------------------------------------------------- +// Link rewriting (URL-aware — preserves external https:// links) +// --------------------------------------------------------------------------- - const product = data.products[subdir]; - // Ensure 'next' appears if folder exists - const nextPath = path.join(__dirname, '..', '..', subdir, 'next'); - if (fs.existsSync(nextPath) && !product.versions.includes('next')) { - product.versions.push('next'); +/** + * Rewrites internal doc links in all MDX files under dirPath. + * Uses perl alternation: matches a full external URL first (preserves it), + * or the internal path pattern (replaces it). + * GitHub links with version strings in their paths are never modified. + */ +function rewriteInternalLinks(dirPath, fromSlug, toSlug) { + // Main link rewrite: /fromSlug/ → /toSlug/ + const perlCmd = `find "${dirPath}" -name "*.mdx" -type f -exec perl -i'' -pe 's{(https?://\\S+)|/${fromSlug}/}{defined($1)?$1:"/${toSlug}/"}ge' {} \\;`; + execSync(perlCmd); + + // Fix bare /documentation/ hrefs missing the product prefix (lookbehind keeps href= context) + const fixRelativeCmd = `find "${dirPath}" -name "*.mdx" -type f -exec perl -i'' -pe 's{(https?://\\S+)|(?<=href=")/documentation/}{defined($1)?$1:"/${toSlug}/documentation/"}ge' {} \\;`; + execSync(fixRelativeCmd); +} + +// --------------------------------------------------------------------------- +// Archive: latest/ → / +// --------------------------------------------------------------------------- + +function archiveLatest(archiveVersion, subdir) { + const latestPath = path.join(__dirname, '..', '..', subdir, 'latest'); + const targetPath = path.join(__dirname, '..', '..', subdir, archiveVersion); + + printInfo(`Archiving ${subdir}/latest/ → ${subdir}/${archiveVersion}/...`); + execSync(`rm -rf "${targetPath}" && mkdir -p "${targetPath}"`); + execSync(`cp -R "${latestPath}/." "${targetPath}/"`); + + rewriteInternalLinks(targetPath, `${subdir}/latest`, `${subdir}/${archiveVersion}`); + printSuccess(`Archived latest/ → ${archiveVersion}/`); + + return { targetPath, latestPath }; +} + +// --------------------------------------------------------------------------- +// Promote: next/ → latest/ +// --------------------------------------------------------------------------- + +function promoteNextToLatest(subdir) { + const nextPath = path.join(__dirname, '..', '..', subdir, 'next'); + const latestPath = path.join(__dirname, '..', '..', subdir, 'latest'); + + if (!fs.existsSync(nextPath)) { + throw new Error(`next/ directory not found: ${nextPath}`); } - if (freezeVersion && !product.versions.includes(freezeVersion)) { - product.versions.push(freezeVersion); + + printInfo(`Promoting ${subdir}/next/ → ${subdir}/latest/...`); + execSync(`rm -rf "${latestPath}" && mkdir -p "${latestPath}"`); + execSync(`cp -R "${nextPath}/." "${latestPath}/"`); + + rewriteInternalLinks(latestPath, `${subdir}/next`, `${subdir}/latest`); + stripNoindexFromDir(latestPath); + printSuccess('Promoted next/ → latest/'); +} + +// --------------------------------------------------------------------------- +// Strip noindex + canonical from promoted latest/ MDX files +// --------------------------------------------------------------------------- + +function stripNoindexFromDir(dirPath) { + const output = execSync(`find "${dirPath}" -name "*.mdx" -type f`, { encoding: 'utf8' }); + const mdxFiles = output.trim().split('\n').filter(Boolean); + + let stripped = 0; + for (const filePath of mdxFiles) { + let content = fs.readFileSync(filePath, 'utf8'); + const original = content; + // Remove noindex and canonical lines from inside front matter + content = content.replace(/^noindex: true\n/m, ''); + content = content.replace(/^canonical: '.*'\n/m, ''); + if (content !== original) { fs.writeFileSync(filePath, content); stripped++; } } - // Maintain stable ordering: newest first for versions (excluding 'next') - const stable = product.versions.filter(v => v !== 'next' && /^v\d+\.\d+(?:\.(?:\d+|x))?$/.test(v)).sort(compareVersionsDesc); - const rest = product.versions.filter(v => v === 'next' || !/^v\d+\.\d+(?:\.(?:\d+|x))?$/.test(v)); - product.versions = [...rest, ...stable]; - - // Track the upcoming development version label - if (newVersion && validateVersionFormat(newVersion)) { - product.nextDev = newVersion; + + printSuccess(`Stripped noindex/canonical from ${stripped} files in latest/`); +} + +// --------------------------------------------------------------------------- +// Inject noindex + canonical into next/ MDX files +// --------------------------------------------------------------------------- + +/** + * Walks every .mdx file in next/ and injects front matter: + * noindex: true + * canonical: //latest/ (if matching page exists in latest/) + * //latest/ (fallback) + * + * Skips files that already have noindex: true. + */ +function injectNoindexNext(subdir) { + const nextPath = path.join(__dirname, '..', '..', subdir, 'next'); + const latestPath = path.join(__dirname, '..', '..', subdir, 'latest'); + const hasLatest = fs.existsSync(latestPath); + + const output = execSync(`find "${nextPath}" -name "*.mdx" -type f`, { encoding: 'utf8' }); + const mdxFiles = output.trim().split('\n').filter(Boolean); + + let tagged = 0, withSpecificCanonical = 0; + + for (const filePath of mdxFiles) { + let content = fs.readFileSync(filePath, 'utf8'); + if (content.includes('noindex: true')) continue; + + const relPath = path.relative(nextPath, filePath); + const latestEquivalent = hasLatest ? path.join(latestPath, relPath) : null; + const hasMatch = latestEquivalent && fs.existsSync(latestEquivalent); + const canonicalUrl = hasMatch + ? `${BASE_URL}/${subdir}/latest/${relPath.replace(/\.mdx$/, '')}` + : `${BASE_URL}/${subdir}/latest/`; + if (hasMatch) withSpecificCanonical++; + + const injection = `noindex: true\ncanonical: '${canonicalUrl}'`; + + if (content.startsWith('---\n')) { + content = content.replace(/^---\n/, `---\n${injection}\n`); + } else { + content = `---\n${injection}\n---\n\n${content}`; + } + + fs.writeFileSync(filePath, content); + tagged++; } - saveVersionsRegistry(data, versionsPath); - printSuccess(`Versions registry updated for ${subdir}`); + printSuccess(`Tagged ${tagged} next/ files with noindex (${withSpecificCanonical} specific canonical, ${tagged - withSpecificCanonical} fallback)`); +} + +// --------------------------------------------------------------------------- +// Inject noindex + canonical into archived version MDX files +// --------------------------------------------------------------------------- + +/** + * Walks every .mdx file in archivedPath and injects front matter: + * noindex: true + * canonical: //latest/ (if matching page exists in latest/) + * //latest/ (fallback if page was deleted/renamed) + * + * Skips files that already have noindex: true. + * Never overwrites existing canonical values. + */ +function injectNoindexCanonical(archivedPath, latestPath, subdir) { + const output = execSync(`find "${archivedPath}" -name "*.mdx" -type f`, { encoding: 'utf8' }); + const mdxFiles = output.trim().split('\n').filter(Boolean); + + let tagged = 0, withSpecificCanonical = 0; + + for (const filePath of mdxFiles) { + let content = fs.readFileSync(filePath, 'utf8'); + + // Already tagged — skip + if (content.includes('noindex: true')) continue; + + // Determine canonical URL + const relPath = path.relative(archivedPath, filePath); + const latestEquivalent = path.join(latestPath, relPath); + const hasMatch = fs.existsSync(latestEquivalent); + const canonicalUrl = hasMatch + ? `${BASE_URL}/${subdir}/latest/${relPath.replace(/\.mdx$/, '')}` + : `${BASE_URL}/${subdir}/latest/`; + if (hasMatch) withSpecificCanonical++; + + const injection = `noindex: true\ncanonical: '${canonicalUrl}'`; + + // Inject into existing front matter or prepend a new block + if (content.startsWith('---\n')) { + content = content.replace(/^---\n/, `---\n${injection}\n`); + } else { + content = `---\n${injection}\n---\n\n${content}`; + } + + fs.writeFileSync(filePath, content); + tagged++; + } + + printSuccess(`Tagged ${tagged} files with noindex (${withSpecificCanonical} specific canonical, ${tagged - withSpecificCanonical} fallback to latest/ root)`); +} + +// --------------------------------------------------------------------------- +// Navigation (docs.json) +// --------------------------------------------------------------------------- + +/** + * Deep-clones obj, replacing string values that start with fromPrefix. + */ +function cloneWithPathRewrite(obj, fromPrefix, toPrefix) { + if (typeof obj === 'string') return obj.startsWith(fromPrefix) ? toPrefix + obj.slice(fromPrefix.length) : obj; + if (Array.isArray(obj)) return obj.map(x => cloneWithPathRewrite(x, fromPrefix, toPrefix)); + if (obj && typeof obj === 'object') { + const out = {}; + for (const k of Object.keys(obj)) out[k] = cloneWithPathRewrite(obj[k], fromPrefix, toPrefix); + return out; + } + return obj; } -function updateNavigation(version, subdir) { +/** + * Updates docs.json navigation for a freeze: + * + * - Clones the 'latest' nav entry → new archive entry with paths latest/ → archiveVersion/ + * - Updates the 'latest' entry's display version label and "Latest" tag + * - Keeps 'next' hidden and untouched + * + * If no 'latest' entry exists yet (first freeze), falls back to cloning from 'next'. + */ +function updateNavigation(subdir, archiveVersion, newDisplayVersion) { const docsJsonPath = path.join(__dirname, '..', '..', 'docs.json'); const docsJson = JSON.parse(fs.readFileSync(docsJsonPath, 'utf8')); - // Resolve dropdown label from subdir - const dropdownLabel = (subdir || '').toUpperCase(); // evm -> EVM, sdk -> SDK, ibc -> IBC - + const dropdownLabel = subdir.toUpperCase(); if (!docsJson.navigation) docsJson.navigation = {}; if (!Array.isArray(docsJson.navigation.dropdowns)) docsJson.navigation.dropdowns = []; - // Find or create dropdown for this subdir let dropdown = docsJson.navigation.dropdowns.find(d => d.dropdown === dropdownLabel); if (!dropdown) { dropdown = { dropdown: dropdownLabel, versions: [] }; @@ -252,289 +380,248 @@ function updateNavigation(version, subdir) { } if (!Array.isArray(dropdown.versions)) dropdown.versions = []; - // Create versioned navigation by updating paths - function updatePaths(obj, fromPrefix, toPrefix) { - if (typeof obj === 'string') { - return obj.startsWith(fromPrefix) ? obj.replace(fromPrefix, toPrefix) : obj; - } - if (Array.isArray(obj)) { - return obj.map(item => updatePaths(item, fromPrefix, toPrefix)); - } - if (typeof obj === 'object' && obj !== null) { - const updated = {}; - for (const key in obj) { - updated[key] = updatePaths(obj[key], fromPrefix, toPrefix); - } - return updated; - } - return obj; - } + // Find the 'latest' nav entry by checking tab paths for /subdir/latest/ + const latestEntry = dropdown.versions.find(v => + (v.tabs || []).some(t => JSON.stringify(t).includes(`${subdir}/latest/`)) + ); - // Try to find 'next' version first, if not use the latest version as template - let nextVersion = dropdown.versions.find(v => v.version === 'next'); - let templateVersion = nextVersion; - - if (!nextVersion) { - // If 'next' doesn't exist, try to use the latest existing version as template - if (dropdown.versions.length > 0) { - // Use the first version (should be the most recent) as template - templateVersion = dropdown.versions[0]; - printWarning(`No 'next' version found. Using '${templateVersion.version}' as template for creating frozen version '${version}'.`); - - // Create a 'next' version from the template - nextVersion = updatePaths(templateVersion, `${subdir}/${templateVersion.version}/`, `${subdir}/next/`); - if (nextVersion && typeof nextVersion === 'object') { - nextVersion.version = 'next'; - } - } else { - throw new Error(`No versions found in navigation for dropdown ${dropdownLabel}. - -Please add at least one version entry to docs.json before freezing: -{ - "dropdown": "${dropdownLabel}", - "versions": [ - { - "version": "next", - "tabs": [ /* your navigation structure */ ] - } - ] -}`); - } + // Fall back to 'next' entry if no 'latest' yet + const templateEntry = latestEntry || dropdown.versions.find(v => v.version === 'next'); + if (!templateEntry) { + throw new Error(`No 'latest' or 'next' nav entry found for ${dropdownLabel}. Add one before freezing.`); } - // Create the frozen version navigation from the template - const sourcePrefix = templateVersion.version === 'next' - ? `${subdir}/next/` - : `${subdir}/${templateVersion.version}/`; - const targetPrefix = `${subdir}/${version}/`; + const fromPrefix = latestEntry ? `${subdir}/latest/` : `${subdir}/next/`; - const versionedNavigation = updatePaths(templateVersion, sourcePrefix, targetPrefix); - if (versionedNavigation && typeof versionedNavigation === 'object') { - versionedNavigation.version = version; - } + // Clone template → archive entry + const archiveEntry = cloneWithPathRewrite(templateEntry, fromPrefix, `${subdir}/${archiveVersion}/`); + archiveEntry.version = archiveVersion; + delete archiveEntry.tag; + delete archiveEntry.hidden; - // Now reorganize versions list: - // 1. Remove the version being added if it already exists - dropdown.versions = dropdown.versions.filter(v => v.version !== version); + // Update the 'latest' entry display version and badge + if (latestEntry) { + latestEntry.version = newDisplayVersion; + latestEntry.tag = 'Latest'; + delete latestEntry.hidden; + } else { + // First freeze: clone next → latest entry, add it + const newLatestEntry = cloneWithPathRewrite(templateEntry, `${subdir}/next/`, `${subdir}/latest/`); + newLatestEntry.version = newDisplayVersion; + newLatestEntry.tag = 'Latest'; + delete newLatestEntry.hidden; + dropdown.versions.unshift(newLatestEntry); + } - // 2. Separate 'next' from other versions - const nextEntry = dropdown.versions.find(v => v.version === 'next'); - const otherVersions = dropdown.versions.filter(v => v.version !== 'next'); + // Remove existing entry for this archive version if present + dropdown.versions = dropdown.versions.filter(v => v.version !== archiveVersion); - // 3. Add the new frozen version at the top of other versions - otherVersions.unshift(versionedNavigation); + // Rebuild order: next (Unreleased) → latest → stable archived (newest first) + const nextEntry = dropdown.versions.find(v => v.version === 'next'); + if (nextEntry) { nextEntry.tag = 'Unreleased'; delete nextEntry.hidden; } + const latestNav = dropdown.versions.find(v => v.tag === 'Latest'); + const stableEntries = dropdown.versions.filter(v => v.version !== 'next' && v.tag !== 'Latest'); - // 4. Rebuild versions list with frozen versions first, then 'next' at the bottom - dropdown.versions = [...otherVersions]; + // Insert archive entry in the right place + stableEntries.push(archiveEntry); + stableEntries.sort((a, b) => compareVersionsDesc(a.version, b.version)); - // 5. Always ensure 'next' exists at the bottom - if (!dropdown.versions.find(v => v.version === 'next')) { - dropdown.versions.push(nextVersion); - } + // Order: latest → next → stable archived (newest first) + // latest is first so it is the default version shown on the site + dropdown.versions = [ + ...(latestNav ? [latestNav] : []), + ...(nextEntry ? [nextEntry] : []), + ...stableEntries, + ]; fs.writeFileSync(docsJsonPath, JSON.stringify(docsJson, null, 2) + '\n'); - printSuccess(`Navigation updated for version ${version}`); - if (!nextEntry) { - printSuccess(`Added 'next' version to navigation (will remain at bottom for continued development)`); - } + printSuccess(`Navigation updated: ${archiveVersion} archived, latest labeled as ${newDisplayVersion}`); } -function copyAndUpdateDocs(currentVersion, subdir) { - printInfo('Creating version directory...'); +// --------------------------------------------------------------------------- +// Versions registry update +// --------------------------------------------------------------------------- - // The 'next' directory is the working directory containing latest documentation. - // It gets COPIED to create a frozen version snapshot, but the original 'next' remains unchanged - // for continued development. This allows us to: - // 1. Preserve historical documentation at // - // 2. Continue updating docs in /next/ for future releases - const sourcePath = path.join(__dirname, '..', '..', subdir, 'next'); - const targetPath = path.join(__dirname, '..', '..', subdir, currentVersion); +function updateVersionsRegistry({ subdir, archiveVersion, newDisplayVersion }) { + const { data, path: versionsPath } = loadVersionsRegistry(); + if (!data.products[subdir]) data.products[subdir] = { versions: [], defaultVersion: 'latest' }; - // Verify source exists - if (!fs.existsSync(sourcePath)) { - throw new Error(`Source directory does not exist: ${sourcePath}\n\nThe 'next' directory must exist before freezing a version.`); + const product = data.products[subdir]; + + // Add archive version if not present + if (archiveVersion && !product.versions.includes(archiveVersion)) { + product.versions.push(archiveVersion); } - // Reset target to avoid nested 'next/' and copy contents - execSync(`rm -rf "${targetPath}" && mkdir -p "${targetPath}"`); - execSync(`cp -R "${sourcePath}/." "${targetPath}/"`); + // Ensure latest and next are tracked + const repoRoot = path.join(__dirname, '..', '..'); + if (fs.existsSync(path.join(repoRoot, subdir, 'latest')) && !product.versions.includes('latest')) { + product.versions.unshift('latest'); + } + if (fs.existsSync(path.join(repoRoot, subdir, 'next')) && !product.versions.includes('next')) { + product.versions.push('next'); + } - // Update internal links in frozen version only (source 'next' remains unchanged) - printInfo('Updating internal links in frozen version...'); - // Convert /{subdir}/next/ links to /{subdir}/{version}/ - const findCmd = `find "${targetPath}" -name "*.mdx" -type f -exec sed -i '' "s|/${subdir}/next/|/${subdir}/${currentVersion}/|g" {} \\;`; - execSync(findCmd); + // Sort: special first, then stable newest-first + const stable = product.versions.filter(v => /^v\d+\.\d+/.test(v)).sort(compareVersionsDesc); + const special = ['latest', 'next'].filter(s => product.versions.includes(s)); + product.versions = [...special, ...stable]; - // Convert incomplete paths that are missing the subdir prefix - // /documentation/ → /{subdir}/{version}/documentation/ - const fixRelativeCmd = `find "${targetPath}" -name "*.mdx" -type f -exec sed -i '' 's|href="/documentation/|href="/${subdir}/${currentVersion}/documentation/|g' {} \\;`; - execSync(fixRelativeCmd); + product.defaultVersion = 'latest'; + if (newDisplayVersion) product.latestDisplayVersion = newDisplayVersion; - printSuccess(`Documentation copied from 'next' to '${currentVersion}' and links updated`); - printInfo(`The 'next' directory remains unchanged for continued development`); + saveVersionsRegistry(data, versionsPath); + printSuccess(`versions.json updated for ${subdir} (latestDisplayVersion: ${newDisplayVersion})`); } -function createVersionMetadata(currentVersion, newVersion, subdir) { - const metadataPath = path.join(__dirname, '..', '..', subdir, currentVersion, '.version-metadata.json'); - const frozenPath = path.join(__dirname, '..', '..', subdir, currentVersion, '.version-frozen'); +// --------------------------------------------------------------------------- +// Version metadata +// --------------------------------------------------------------------------- +function createVersionMetadata(archiveVersion, subdir, newDisplayVersion) { + const metadataPath = path.join(__dirname, '..', '..', subdir, archiveVersion, '.version-metadata.json'); + const frozenPath = path.join(__dirname, '..', '..', subdir, archiveVersion, '.version-frozen'); const metadata = { - version: currentVersion, + version: archiveVersion, frozenDate: new Date().toISOString().split('T')[0], frozenTimestamp: new Date().toISOString(), - nextVersion: newVersion, - eipSheetTab: currentVersion + nextDisplayVersion: newDisplayVersion }; - fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); - fs.writeFileSync(frozenPath, `${currentVersion} - Frozen on ${metadata.frozenDate}`); - + fs.writeFileSync(frozenPath, `${archiveVersion} - Frozen on ${metadata.frozenDate}`); printSuccess('Version metadata created'); } +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + async function main() { - // Interactive mode by default for manual execution - const interactive = !['1','true','yes'].includes(String(process.env.NON_INTERACTIVE || '').toLowerCase()); + const interactive = !['1', 'true', 'yes'].includes(String(process.env.NON_INTERACTIVE || '').toLowerCase()); - // Determine docs subdir to version first + // 1. Subdir const choices = listDocsSubdirs(); let subdir; if (interactive) { - const pretty = choices.length ? ` [${choices.join(', ')}]` : ''; - subdir = (await prompt(`Enter the docs subdirectory to version${pretty}: `)).trim(); + subdir = (await prompt(`Enter the docs subdirectory to freeze [${choices.join(', ')}]: `)).trim(); } else { subdir = process.env.DOCS_SUBDIR || process.env.SUBDIR; } - if (!subdir) { - printError('No docs subdirectory provided'); - process.exit(1); - } - if (!choices.includes(subdir)) { - printWarning(`Subdirectory "${subdir}" not found at repo root. Proceeding anyway.`); - } + if (!subdir) { printError('No subdirectory provided'); process.exit(1); } + + const repoRoot = path.join(__dirname, '..', '..'); + const latestPath = path.join(repoRoot, subdir, 'latest'); + const nextPath = path.join(repoRoot, subdir, 'next'); + const hasLatest = fs.existsSync(latestPath); + const hasNext = fs.existsSync(nextPath); - // Load and display versions registry context for the selected product + // Load registry to get latestDisplayVersion const { data: registry } = loadVersionsRegistry(); const product = registry.products && registry.products[subdir]; - if (product) { - console.log('\n Product versions (from versions.json):'); - console.log(` - versions: ${JSON.stringify(product.versions || [])}`); - if (product.defaultVersion) console.log(` - defaultVersion: ${product.defaultVersion}`); - if (product.nextDev) console.log(` - nextDev: ${product.nextDev}`); - } else { - printWarning('No product entry found in versions.json for this subdir. A new entry will be created.'); - } + const storedDisplayVersion = product && product.latestDisplayVersion; - // Determine the version we are freezing - let currentVersion; - if (interactive) { - currentVersion = (await prompt('Enter the version to freeze (e.g., v0.4.x): ')).trim(); - } else { - currentVersion = getCurrentVersion(subdir); - if (!currentVersion) { - printError('Freeze version not provided in non-interactive mode'); - process.exit(1); + console.log('\n' + '='.repeat(50)); + console.log(' Documentation Version Manager'); + console.log('='.repeat(50)); + console.log(`\n Subdir: ${colors.blue}${subdir}${colors.reset}`); + console.log(` latest/: ${hasLatest ? colors.green + 'exists' : colors.yellow + 'not yet created'}${colors.reset}`); + console.log(` next/: ${hasNext ? colors.green + 'exists' : colors.red + 'MISSING'}${colors.reset}`); + if (storedDisplayVersion) console.log(` Current display version: ${storedDisplayVersion}`); + + if (!hasNext) { printError(`${subdir}/next/ does not exist. Create it before freezing.`); process.exit(1); } + + // 2. Archive version (what to call the outgoing latest/) + let archiveVersion; + if (hasLatest) { + if (interactive) { + const suggested = storedDisplayVersion ? ` [default: ${storedDisplayVersion}]` : ''; + const input = (await prompt(`Archive version label (what to call the outgoing latest/)${suggested}: `)).trim(); + archiveVersion = input || storedDisplayVersion || ''; + } else { + archiveVersion = process.env.ARCHIVE_VERSION || storedDisplayVersion; } - } - if (!validateVersionFormat(currentVersion)) { - printError(`Invalid freeze version format: ${currentVersion}`); - process.exit(1); + if (!archiveVersion || !validateVersionFormat(archiveVersion)) { + printError(`Invalid archive version: ${archiveVersion}`); process.exit(1); + } + } else { + printInfo('No latest/ found — this is a first-time setup. Skipping archive step.'); } - // Get new development version from environment or prompt (default to product.nextDev when available) - let newVersion; + // 3. New display version for latest/ (e.g. "v0.54") + let newDisplayVersion; if (interactive) { - const defaultDev = product && product.nextDev ? ` [default: ${product.nextDev}]` : ''; - const input = (await prompt(`\nEnter the new development version (e.g., v0.5.0 or v0.5.x)${defaultDev}: `)).trim(); - newVersion = input || (product && product.nextDev) || ''; + newDisplayVersion = (await prompt('New display version for latest/ (e.g. v0.54): ')).trim(); } else { - newVersion = process.env.NEW_VERSION; + newDisplayVersion = process.env.NEW_DISPLAY_VERSION; } - if (!validateVersionFormat(newVersion)) { - printError(`Invalid new development version format: ${newVersion}`); - process.exit(1); + if (!newDisplayVersion || !validateVersionFormat(newDisplayVersion)) { + printError(`Invalid display version: ${newDisplayVersion}`); process.exit(1); } - // Confirm release notes fetch intent (still auto if missing, default yes) + // 4. Release notes check let shouldFetchReleaseNotes = true; if (interactive) { - const ans = (await prompt('If release notes are missing, fetch from the product repo? [Y/n]: ')).trim().toLowerCase(); + const ans = (await prompt('Fetch release notes from upstream if missing? [Y/n]: ')).trim().toLowerCase(); if (ans === 'n' || ans === 'no') shouldFetchReleaseNotes = false; } - console.log('\n' + '='.repeat(50)); - console.log(' Documentation Version Manager'); - console.log('='.repeat(50)); - console.log(`\n Subdir: ${colors.blue}${subdir}${colors.reset}`); - console.log(` Freezing: ${colors.yellow}${currentVersion}${colors.reset}`); - console.log(` Next dev: ${colors.green}${newVersion}${colors.reset}\n`); + console.log(`\n Freezing: ${colors.yellow}${archiveVersion || '(first run)'}${colors.reset} → archive`); + console.log(` Latest label: ${colors.green}${newDisplayVersion}${colors.reset}\n`); try { - // 1. Check release notes and auto-fetch if missing - if (!(await checkReleaseNotes(currentVersion, subdir))) { + // Step 1: Check / fetch release notes + if (archiveVersion && !(await checkReleaseNotes(archiveVersion, subdir))) { if (shouldFetchReleaseNotes) { - printInfo(`Release notes missing for ${currentVersion} in ${subdir}. Fetching from GitHub...`); + printInfo(`Release notes missing for ${archiveVersion}. Fetching from GitHub...`); try { - execSync(`node "${path.join(__dirname, 'manage-changelogs.js')}" --product ${subdir} --target next --freeze`, { stdio: 'inherit' }); - } catch (e) { - printWarning('Failed to fetch release notes automatically (network/permissions). Proceeding.'); - } - if (!(await checkReleaseNotes(currentVersion, subdir))) { - printWarning(`${currentVersion} still not found in release notes after fetch.`); - } else { - printSuccess('Release notes updated.'); - } - } else { - printWarning('Skipping automatic release notes fetch by user choice.'); + execSync(`node "${path.join(__dirname, 'manage-changelogs.js')}" --product ${subdir} --target ${hasLatest ? 'latest' : 'next'} --freeze`, { stdio: 'inherit' }); + } catch { printWarning('Failed to fetch release notes automatically. Proceeding.'); } } } - // 2. Copy and update documentation - copyAndUpdateDocs(currentVersion, subdir); - - // 2.5. Generate version-specific changelog for frozen version - printInfo(`Generating version-specific changelog for ${currentVersion}...`); - try { - execSync(`node "${path.join(__dirname, 'manage-changelogs.js')}" --product ${subdir} --target ${currentVersion} --freeze`, { stdio: 'inherit' }); - printSuccess('Version-specific changelog generated'); - } catch (e) { - printWarning('Failed to generate version-specific changelog. Proceeding.'); + // Step 2: Archive latest/ → / + let archivedPath; + if (hasLatest && archiveVersion) { + const result = archiveLatest(archiveVersion, subdir); + archivedPath = result.targetPath; + + // Inject noindex + canonical into the archive + printInfo('Injecting noindex and canonical into archived version...'); + injectNoindexCanonical(archivedPath, latestPath, subdir); + + // Generate version-specific changelog for the archive + printInfo(`Generating changelog for ${archiveVersion}...`); + try { + execSync(`node "${path.join(__dirname, 'manage-changelogs.js')}" --product ${subdir} --target ${archiveVersion} --freeze`, { stdio: 'inherit' }); + printSuccess('Changelog generated'); + } catch { printWarning('Failed to generate changelog. Proceeding.'); } } - // 3. Handle Google Sheets and EIP reference (EVM only) - if (subdir === 'evm') { - let shouldRunSheets = !['1','true','yes'].includes(String(process.env.SKIP_SHEETS || '').toLowerCase()); - if (interactive) { - const ans = (await prompt('Create Google Sheets snapshot and versioned EIP reference for EVM? [Y/n]: ')).trim().toLowerCase(); - if (ans === 'n' || ans === 'no') shouldRunSheets = false; - } - if (shouldRunSheets) { - printInfo('Processing Google Sheets and EIP reference (EVM only)...'); - execSync(`node "${path.join(__dirname, 'sheets-manager.js')}" "${currentVersion}" evm`, { stdio: 'inherit' }); - } else { - printInfo('Skipping Google Sheets/EIP reference'); - } - } else { - printInfo('Skipping Google Sheets/EIP reference'); - } + // Step 3: Promote next/ → latest/ (strips noindex from promoted files) + promoteNextToLatest(subdir); - // 4. Update navigation and versions - updateNavigation(currentVersion, subdir); - updateVersionsRegistry({ subdir, freezeVersion: currentVersion, newVersion }); + // Step 3b: Inject noindex + canonical into next/ for future crawl protection + printInfo('Injecting noindex into next/ pages...'); + injectNoindexNext(subdir); - // 5. Create metadata - createVersionMetadata(currentVersion, newVersion, subdir); + // Step 4: Update navigation and registry + if (archiveVersion) { + updateNavigation(subdir, archiveVersion, newDisplayVersion); + updateVersionsRegistry({ subdir, archiveVersion, newDisplayVersion }); + createVersionMetadata(archiveVersion, subdir, newDisplayVersion); + } else { + // First-time: just update versions.json to record the new latest + updateVersionsRegistry({ subdir, archiveVersion: null, newDisplayVersion }); + printWarning('docs.json not updated (no archive version). Add the latest/ nav entry manually or re-run after latest/ is established.'); + } console.log('\n' + '='.repeat(50)); - console.log(' Version freeze completed successfully!'); + console.log(' Version freeze completed!'); console.log('='.repeat(50)); - console.log(`\n Status:`); - console.log(` ✓ Version ${currentVersion} frozen at ${subdir}/${currentVersion}/`); - console.log(` ✓ Development continues with ${newVersion} in ${subdir}/next/`); + if (archiveVersion) console.log(` ✓ ${archiveVersion} archived at ${subdir}/${archiveVersion}/ (noindex injected)`); + console.log(` ✓ next/ promoted → latest/ (now labeled ${newDisplayVersion})`); console.log(` ✓ Navigation and registry updated`); - if (subdir === 'evm' && !['1','true','yes'].includes(String(process.env.SKIP_SHEETS || '').toLowerCase())) { - console.log(` ✓ Google Sheets tab created: ${currentVersion}`); - } + console.log(`\n next/ is unchanged and continues as the dev workspace.\n`); } catch (error) { printError(`Version freeze failed: ${error.message}`);