diff --git a/docusaurus.config.js b/docusaurus.config.js index 5f6cf45ce7..3d0de36c98 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -9,6 +9,7 @@ import { resolve } from 'path'; import { themes as prismThemes } from 'prism-react-renderer'; import { generateDocusaurusPlugins, generateNavbarDropdowns, PRODUCTS, versionToUrl, getDefaultVersion, getLatestVersionUrlMap, getActiveProducts, getActiveVersions, generateRouteBasePath } from './src/config/products.js'; import { accessAnalyzer261Redirects } from './src/config/redirects/accessanalyzer-26.1.js'; +import { passwordPolicyEnforcerDeversionRedirects } from './src/config/redirects/passwordpolicyenforcer-deversion.js'; // Strip TypeScript syntax from a generated sidebar.ts and return its apisidebar array. // Returns [] if the file doesn't exist yet (before gen-api-docs has run). @@ -63,10 +64,30 @@ const activeVersionsByProduct = Object.fromEntries( // DOCS_PRODUCT_LATEST_ONLY narrows the active list to one version, which would // make a genuinely multi-version product look collapsed and start rewriting its // valid versioned URLs in local single-product builds. +// A version can opt into "root-only" stale-link handling (redirectStaleVersionsToRoot) +// instead of the default "preserve the sub-path" handling below — see +// rootOnlyUnversionedDocsBasePaths. const unversionedDocsBasePaths = redirectProducts .map(product => { if (product.versions.length !== 1) return null; const [version] = product.versions; + if (version.redirectStaleVersionsToRoot) return null; + const routeBasePath = version.customRoutePath || generateRouteBasePath(product.path, version.version); + return routeBasePath === product.path ? `/${routeBasePath}` : null; + }) + .filter(Boolean); + +// Same idea as unversionedDocsBasePaths, but for a product whose old versions' +// page structure isn't guaranteed to line up with the new unversioned latest +// (e.g. passwordpolicyenforcer, whose 10.2 layout was reorganized in 11.x/12.0). +// Rather than guess at an equivalent sub-path, any stale versioned URL under +// these base paths sends the reader to the docs root. Opt in per-version via +// `redirectStaleVersionsToRoot: true` on the version object. +const rootOnlyUnversionedDocsBasePaths = redirectProducts + .map(product => { + if (product.versions.length !== 1) return null; + const [version] = product.versions; + if (!version.redirectStaleVersionsToRoot) return null; const routeBasePath = version.customRoutePath || generateRouteBasePath(product.path, version.version); return routeBasePath === product.path ? `/${routeBasePath}` : null; }) @@ -145,6 +166,7 @@ const config = { activeProductIds, activeVersionsByProduct, unversionedDocsBasePaths, + rootOnlyUnversionedDocsBasePaths, }, clientModules: ['./src/clientModules/scrollBehavior.js'], presets: [ @@ -175,23 +197,34 @@ const config = { }; }, - // Google Analytics - [ - '@docusaurus/plugin-google-gtag', - { - trackingID: 'G-FZPWSDMTEX', - anonymizeIP: true, - }, - ], + // Google Analytics — only loaded for production builds. In dev + // (npm run start), the gtag script often can't load (network, ad + // blockers), leaving window.gtag undefined and throwing a runtime error + // overlay on every route change. + ...(process.env.NODE_ENV === 'production' ? [ + [ + '@docusaurus/plugin-google-gtag', + { + trackingID: 'G-FZPWSDMTEX', + anonymizeIP: true, + }, + ], + ] : []), // Client-side redirects - redirect base product URLs to latest version [ '@docusaurus/plugin-client-redirects', { redirects: [ ...(activeProductIds.includes('accessanalyzer') ? accessAnalyzer261Redirects : []), + ...(activeProductIds.includes('passwordpolicyenforcer') ? passwordPolicyEnforcerDeversionRedirects : []), ...redirectProducts.filter(product => { - // Only create redirects for products with multiple versions (not just 'current') - return !(product.versions.length === 1 && product.versions[0].version === 'current'); + // Only create redirects for products with multiple versions (not just 'current'), + // and skip products whose latest version already serves at the bare product + // path (e.g. passwordpolicyenforcer 12.0) — redirecting the base path to + // itself would be a no-op redirect loop. + if (product.versions.length === 1 && product.versions[0].version === 'current') return false; + const latestVersion = getDefaultVersion(product); + return latestVersion.customRoutePath !== product.path; }).map(product => { const latestVersion = getDefaultVersion(product); const latestVersionUrl = versionToUrl(latestVersion.version); diff --git a/src/config/products.js b/src/config/products.js index c66393c8d6..340acfbd5b 100644 --- a/src/config/products.js +++ b/src/config/products.js @@ -336,40 +336,26 @@ export const PRODUCTS = [ path: 'docs/passwordpolicyenforcer', categories: ['Directory Management'], icon: '', + // De-versioned 2026-09: only 12.0 is built/served now, at the bare product + // path (no version segment). The 11.2/11.1/11.0/10.2 entries that used to + // live here were removed, not deleted — their docs, images, and sidebar + // files are untouched on disk (docs/passwordpolicyenforcer//, + // sidebars/passwordpolicyenforcer/.js). To revive a version, + // re-add its entry here (see git history of this file for the exact + // shape) and it picks back up where it left off. Every old versioned URL + // (including the old /12_0 one) now redirects to the bare product root — + // see passwordPolicyEnforcerDeversionRedirects in docusaurus.config.js for + // known static paths, and redirectStaleVersionsToRoot below for the + // catch-all client-side fallback for unlisted old deep links. versions: [ { version: '12.0', label: '12.0', isLatest: true, sidebarFile: './sidebars/passwordpolicyenforcer/12.0.js', - }, - { - version: '11.2', - label: '11.2', - isLatest: false, - hidden: true, - sidebarFile: './sidebars/passwordpolicyenforcer/11.2.js', - }, - { - version: '11.1', - label: '11.1', - isLatest: false, - hidden: true, - sidebarFile: './sidebars/passwordpolicyenforcer/11.1.js', - }, - { - version: '11.0', - label: '11.0', - isLatest: false, - hidden: true, - sidebarFile: './sidebars/passwordpolicyenforcer/11.0.js', - }, - { - version: '10.2', - label: '10.2', - isLatest: false, - hidden: true, - sidebarFile: './sidebars/passwordpolicyenforcer/10.2.js', + customRoutePath: 'docs/passwordpolicyenforcer', + customDocPath: 'docs/passwordpolicyenforcer/12.0', + redirectStaleVersionsToRoot: true, }, ], defaultVersion: '12.0', @@ -829,7 +815,9 @@ export function getActiveVersions(product) { /** * Build a map of product ID → latest URL-version string. * Used by the evergreen-links redirect config to generate version-less aliases. - * Skips single-version 'current' products (their URLs are already version-less). + * Skips single-version 'current' products (their URLs are already version-less), + * and any product whose latest version already serves at the bare product path + * (e.g. passwordpolicyenforcer 12.0) — those have no version segment to alias. */ export function getLatestVersionUrlMap() { const map = {}; @@ -837,6 +825,7 @@ export function getLatestVersionUrlMap() { if (product.versions.length === 1 && product.versions[0].version === 'current') continue; const latest = getDefaultVersion(product); if (!latest) continue; + if (latest.customRoutePath === product.path) continue; const urlVersion = latest.customRoutePath ? latest.customRoutePath.split('/').pop() : versionToUrl(latest.version); diff --git a/src/config/redirects/passwordpolicyenforcer-deversion.js b/src/config/redirects/passwordpolicyenforcer-deversion.js new file mode 100644 index 0000000000..1d46926ffa --- /dev/null +++ b/src/config/redirects/passwordpolicyenforcer-deversion.js @@ -0,0 +1,205 @@ +// Password Policy Enforcer was de-versioned in 2026-09: only 12.0 is built now, +// serving at the bare /docs/passwordpolicyenforcer path. The 11.2/11.1/11.0/10.2 +// versions were removed from the build (their source files are untouched on +// disk, just unplugged from src/config/products.js). +// +// 11.2/11.1/11.0 share 12.0's exact section layout (see +// docs/passwordpolicyenforcer/CLAUDE.md), so old links into them are redirected +// to the same page path under the new unversioned root — computed here by +// walking 12.0's real doc files and checking each one also exists at the same +// relative path in the older version. A handful of 11.1/11.0 pages don't have a +// 12.0 counterpart (removed/renamed features); those are skipped here and fall +// through to the client-side root redirect (rootOnlyUnversionedDocsBasePaths in +// docusaurus.config.js) instead of a broken precise redirect. +// +// 10.2's layout was reorganized for 11.x/12.0, so its redirects use an explicit, +// hand-verified old-path -> new-path map instead, built by reading each old +// page's content/title and matching it to whichever 12.0 page now covers that +// topic (not just matching similar file/folder names). Every 10.2 page has a +// mapped target; any other stale link with no single clear target still falls +// through to the root. + +import { readdirSync, existsSync, statSync } from 'fs'; +import { join, resolve } from 'path'; + +const PRODUCT_DOCS_ROOT = resolve(process.cwd(), 'docs/passwordpolicyenforcer'); +const NEW_PREFIX = '/docs/passwordpolicyenforcer'; + +const EXCLUDED_DIRS = new Set(['kb', '_partials']); + +// List every real doc under a version's folder. Returns { docPath, route } pairs: +// docPath is the file's relative path (extension stripped, used to check the +// same file exists in an older version); route is the URL suffix Docusaurus +// actually serves it at, which isn't always the same as docPath — a doc file +// named the same as its parent folder (e.g. admin/cmdlets/cmdlets.md, +// admin/manage-policies/rules/rules.md) becomes that folder's category index +// instead of an extra path segment, so its route drops the repeated segment +// and gets a trailing slash. +function listDocRoutes(versionDir) { + const docs = []; + function walk(dir, relPrefix) { + if (!existsSync(dir)) return; + for (const entry of readdirSync(dir)) { + if (EXCLUDED_DIRS.has(entry) || entry === 'CLAUDE.md') continue; + const fullPath = join(dir, entry); + if (statSync(fullPath).isDirectory()) { + walk(fullPath, relPrefix ? `${relPrefix}/${entry}` : entry); + continue; + } + if (!/\.mdx?$/.test(entry)) continue; + const base = entry.replace(/\.mdx?$/, ''); + const docPath = relPrefix ? `${relPrefix}/${base}` : base; + const parentFolderName = relPrefix.split('/').pop(); + let route; + if (base === 'index') { + route = relPrefix; + } else if (base === parentFolderName) { + route = `${relPrefix}/`; + } else { + route = docPath; + } + docs.push({ docPath, route }); + } + } + walk(versionDir, ''); + return docs; +} + +// 12.0/11.2/11.1/11.0: redirect every 12.0 route that also exists at the same +// relative path under the older version's own folder. 12.0 itself is included +// so its own old versioned URL (/12_0/) redirects to the new one. +const STRUCTURALLY_IDENTICAL_VERSIONS = ['12.0', '11.2', '11.1', '11.0']; + +function docExistsInVersion(versionDir, docPath) { + const stem = join(versionDir, ...(docPath ? docPath.split('/') : ['index'])); + return existsSync(`${stem}.md`) || existsSync(`${stem}.mdx`); +} + +const exactMatchRedirects = STRUCTURALLY_IDENTICAL_VERSIONS.flatMap((version) => { + const urlVersion = version.replace(/\./g, '_'); + const versionDir = join(PRODUCT_DOCS_ROOT, version); + if (!existsSync(versionDir)) return []; + return listDocRoutes(join(PRODUCT_DOCS_ROOT, '12.0')) + .filter(({ docPath }) => docExistsInVersion(versionDir, docPath)) + .map(({ route }) => ({ + from: route ? `${NEW_PREFIX}/${urlVersion}/${route}` : `${NEW_PREFIX}/${urlVersion}`, + to: route ? `${NEW_PREFIX}/${route}` : NEW_PREFIX, + })); +}); + +// 10.2: layout was reorganized in 11.x/12.0, so map old page -> new page +// explicitly. Verified by comparing file titles/content, not just names. +// '' (index) -> '' (index) covers the version root. +const TEN_TWO_PAGE_MAP = { + '': '', + 'administration/administration_overview': 'admin/administration_overview', + // "Connect to a Configuration" is now covered by the Configuration Console + // overview, which documents connecting to a domain vs. local configuration. + 'administration/connecting': 'admin/configconsole', + 'administration/domain_and_local_policies': 'installation/domain_and_local_policies', + 'administration/hibpupdater': 'admin/hibpupdater', + // The separate automated/manual/general installation pages were consolidated + // into the single server-components installer page. + 'administration/installation/automated_installation': 'installation/installationserver', + 'administration/installation': 'installation/installationserver', + 'administration/installation/manual_installation': 'installation/installationserver', + 'administration/installation/disable_windows_rules': 'installation/disable_windows_rules', + 'administration/installation/writeback': 'admin/writeback', + // Mailer/email settings and license management moved into the global Settings + // page (see its "Mail service", "Notifications", and "License" sections). + 'administration/mailer/command_line_interface': 'admin/settings', + 'administration/mailer/email_delivery_options': 'admin/settings', + // Intro/comparison blurb for the separate Netwrix Password Reset product — + // that comparison (PPE Web vs. Password Reset) now lives in the Web overview. + 'administration/password_reset': 'web-overview/web_overview', + 'administration/mailer/email_message_options': 'admin/settings', + 'administration/mailer': 'admin/settings', + 'administration/properties/license_generator': 'admin/settings', + 'administration/managementconsole/management_console_views': 'admin/configconsole', + 'administration/managementconsole/management_console': 'admin/configconsole', + 'administration/managingpolicies/assigning_policies': 'admin/manage-policies/usersgroups', + 'administration/managingpolicies/creating_a_policy': 'admin/manage-policies/manage_policies', + 'administration/managingpolicies/deleting_a_policy': 'admin/manage-policies/manage_policies', + 'administration/managingpolicies/managing_policies': 'admin/manage-policies/manage_policies', + 'administration/managingpolicies/passphrases': 'admin/manage-policies/passphrases', + 'administration/managingpolicies/policy_priorities': 'admin/manage-policies/manage_policies', + 'administration/managingpolicies/policy_properties': 'admin/manage-policies/policy_properties', + 'administration/managingpolicies/testing_policies': 'admin/manage-policies/testpolicy', + 'administration/passwordpolicyclient/configuring_the_password_policy_client': 'admin/password-policy-client/configuring_the_password_policy_client', + 'administration/passwordpolicyclient/installing_password_policy_client': 'installation/installationclient', + 'administration/passwordpolicyclient/password_policy_client': 'admin/password-policy-client/password_policy_client', + // Message template/rule insert/multilingual customization is documented + // together with the client's other GPO-based configuration steps now. + 'administration/passwordpolicyclient/customizing_message_templates': 'admin/password-policy-client/configuring_the_password_policy_client', + 'administration/passwordpolicyclient/customizing_rule_inserts': 'admin/password-policy-client/configuring_the_password_policy_client', + 'administration/passwordpolicyclient/multilingual_messages': 'admin/password-policy-client/configuring_the_password_policy_client', + 'administration/ppe_tool': 'admin/ppe_tool', + 'administration/properties': 'admin/settings', + 'administration/rules/character_pattern': 'admin/manage-policies/rules/patterns', + 'administration/rules/character_rules': 'admin/manage-policies/rules/character_rules', + 'administration/rules/complexity_rule': 'admin/manage-policies/rules/complexity_rule', + 'administration/rules/compromised_rule': 'admin/manage-policies/rules/compromised_rule', + 'administration/rules/dictionary_rule': 'admin/manage-policies/rules/dictionary_rule', + // "First and Last Character Rules" is now covered by the general Character + // rules page's "In position" option. + 'administration/rules/first_and_last': 'admin/manage-policies/rules/character_rules', + 'administration/rules/history_rule': 'admin/manage-policies/rules/history_rule', + 'administration/rules/keyboard_pattern': 'admin/manage-policies/rules/patterns', + 'administration/rules/length_rule': 'admin/manage-policies/rules/length_rule', + 'administration/rules/maximum_age_rule': 'admin/manage-policies/rules/maximum_age_rule', + 'administration/rules/minimum_age_rule': 'admin/manage-policies/rules/minimum_age_rule', + 'administration/rules/repeating_characters': 'admin/manage-policies/rules/repetition', + 'administration/rules/repeating_pattern': 'admin/manage-policies/rules/repetition', + // rules.md shares its filename with its parent folder, so Docusaurus serves + // it as that folder's category index (trailing slash, no repeated segment). + 'administration/rules': 'admin/manage-policies/rules/', + 'administration/rules/similarity_rule': 'admin/manage-policies/rules/similarity_rule', + // User display name/logon name rules are now options within Similarity. + 'administration/rules/user_display_name_rule': 'admin/manage-policies/rules/similarity_rule', + 'administration/rules/user_logon_name_rule': 'admin/manage-policies/rules/similarity_rule', + 'administration/rules/unique_characters': 'admin/manage-policies/rules/unique_characters', + // "Support Tools" is now a section within System Audit and Support. + 'administration/support_tools': 'admin/systemaudit', + 'administration/troubleshooting': 'admin/troubleshooting', + // Uninstall steps moved into the server-components installer page. + 'administration/uninstall': 'installation/installationserver', + 'administration/upgrading': 'installation/upgrading', + 'evaluation/conclusion': 'evaluation/conclusion', + 'evaluation/configuring_policy_rules': 'evaluation/configuring_policy_rules', + 'evaluation/creatingapasswordpolicy/creating_a_password_policy': 'evaluation/creating-a-password-policy/creating_a_password_policy', + 'evaluation/creatingapasswordpolicy/policy_templates': 'evaluation/creating-a-password-policy/policy_templates', + 'evaluation/enforcing_multiple_policies': 'evaluation/enforcing_multiple_policies', + 'evaluation/evaluation_overview': 'evaluation/evaluation_overview', + 'evaluation/improving_the_password_policy': 'evaluation/improving_the_password_policy', + 'evaluation/installation': 'evaluation/installforeval', + 'evaluation/preparing_the_computer': 'evaluation/preparing_the_computer', + 'evaluation/testing_the_password_policy': 'evaluation/testing_the_password_policy', + 'web/configuration': 'web-overview/configuration', + 'web/editing_html_templates': 'web-overview/editing_html_templates', + 'web/installation': 'web-overview/installationweb', + 'web/securing_web': 'web-overview/securing_web', + 'web/using_web': 'web-overview/using_web', + 'web/web_overview': 'web-overview/web_overview', +}; + +const tenTwoRedirects = Object.entries(TEN_TWO_PAGE_MAP).map(([oldRoute, newRoute]) => ({ + from: oldRoute ? `${NEW_PREFIX}/10_2/${oldRoute}` : `${NEW_PREFIX}/10_2`, + to: newRoute ? `${NEW_PREFIX}/${newRoute}` : NEW_PREFIX, +})); + +// plugin-client-redirects matches `from` literally, with no trailing-slash +// normalization, and writes each redirect to /index.html on disk. A +// trailing-slash `from` and its no-slash sibling resolve to the same file, so +// only the no-slash form is kept — matching how a reader actually types a +// folder-index URL (e.g. .../admin/cmdlets, not .../admin/cmdlets/). +function stripTrailingSlash(entries) { + return entries.map((entry) => ({ + ...entry, + from: entry.from.endsWith('/') && entry.from.length > 1 ? entry.from.slice(0, -1) : entry.from, + })); +} + +export const passwordPolicyEnforcerDeversionRedirects = stripTrailingSlash([ + ...exactMatchRedirects, + ...tenTwoRedirects, +]); diff --git a/src/theme/DocRoot/index.js b/src/theme/DocRoot/index.js index 56b65e9f45..dcad26e31e 100644 --- a/src/theme/DocRoot/index.js +++ b/src/theme/DocRoot/index.js @@ -36,7 +36,8 @@ function DocRootNotFound() { // on the first pass instead of flashing an empty render. const redirectTarget = findVersionlessRedirect( location.pathname, - siteConfig.customFields?.unversionedDocsBasePaths + siteConfig.customFields?.unversionedDocsBasePaths, + siteConfig.customFields?.rootOnlyUnversionedDocsBasePaths ); useEffect(() => { diff --git a/src/theme/NotFound/index.js b/src/theme/NotFound/index.js index 12b6da6274..687af82051 100644 --- a/src/theme/NotFound/index.js +++ b/src/theme/NotFound/index.js @@ -24,7 +24,8 @@ export default function Index() { // JS-disabled visitors. const redirectTarget = findVersionlessRedirect( location.pathname, - siteConfig.customFields?.unversionedDocsBasePaths + siteConfig.customFields?.unversionedDocsBasePaths, + siteConfig.customFields?.rootOnlyUnversionedDocsBasePaths ); useEffect(() => { diff --git a/src/utils/versionlessRedirect.js b/src/utils/versionlessRedirect.js index 218b4b0bdd..e32669d43a 100644 --- a/src/utils/versionlessRedirect.js +++ b/src/utils/versionlessRedirect.js @@ -31,7 +31,23 @@ const VERSION_SEGMENT_RE = /^(?:v?\d+(?:_\d+)*|saas|current)$/i; // so plugin-client-redirects has nothing to emit), so it still answers HTTP 404 // to crawlers and JS-disabled clients and passes no link equity to the new // location. -export function findVersionlessRedirect(pathname, unversionedDocsBasePaths) { +export function findVersionlessRedirect(pathname, unversionedDocsBasePaths, rootOnlyDocsBasePaths) { + // A product opts into "always redirect to root" (rootOnlyDocsBasePaths) instead + // of the default "preserve the sub-path" handling below when its old versions' + // page structure isn't guaranteed to line up with the new unversioned latest + // (see passwordpolicyenforcer in docusaurus.config.js). Checked first so a + // product never needs to appear in both lists. + const rootOnlyBase = rootOnlyDocsBasePaths?.find((base) => + pathname.startsWith(`${base}/`) + ); + if (rootOnlyBase) { + const [maybeVersion] = pathname + .slice(rootOnlyBase.length + 1) + .split('/') + .filter(Boolean); + return maybeVersion && VERSION_SEGMENT_RE.test(maybeVersion) ? `${rootOnlyBase}/` : null; + } + // Each base path is matched with a trailing slash so that products whose base // path is a string prefix of another's (platgovnetsuite vs // platgovnetsuiteflashlight) can't match each other.