From 365bdd741211350e9501e413cd24a2dd62242529 Mon Sep 17 00:00:00 2001 From: Dan Piazza <220388267+DanPiazza-Netwrix@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:24:51 -0400 Subject: [PATCH 1/7] De-version Password Policy Enforcer latest docs to bare product path 12.0 now serves at /docs/passwordpolicyenforcer instead of /docs/passwordpolicyenforcer/12_0. Older versions (11.2, 11.1, 11.0, 10.2) are removed from the active build but left untouched on disk. Old versioned URLs redirect to the unversioned docs root: known version roots get a real static redirect, and any other stale deep link falls back to a client-side redirect via a new opt-in redirectStaleVersionsToRoot mechanism that leaves other de-versioned products' path-preserving redirects unchanged. --- docusaurus.config.js | 32 ++++++++++++- src/config/products.js | 47 +++++++------------ .../passwordpolicyenforcer-deversion.js | 18 +++++++ src/theme/DocRoot/index.js | 3 +- src/theme/NotFound/index.js | 3 +- src/utils/versionlessRedirect.js | 18 ++++++- 6 files changed, 87 insertions(+), 34 deletions(-) create mode 100644 src/config/redirects/passwordpolicyenforcer-deversion.js diff --git a/docusaurus.config.js b/docusaurus.config.js index 5f6cf45ce7..0076aab720 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: [ @@ -189,9 +211,15 @@ const config = { { 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..41bae5114a --- /dev/null +++ b/src/config/redirects/passwordpolicyenforcer-deversion.js @@ -0,0 +1,18 @@ +// 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). +// +// These are static redirects for the well-known version-root URLs so old +// bookmarks/links to those roots get a real (crawlable, no-JS) redirect rather +// than depending on the client-side 404 fallback (findVersionlessRedirect), +// which only fires for a browser that hits a dead route. Deep links into pages +// under those old versions aren't enumerated here — the 10.2 layout in +// particular doesn't map cleanly onto 12.0 — so they fall through to that +// client-side fallback, which sends any old versioned URL to the docs root. +const OLD_VERSION_URL_SEGMENTS = ['12_0', '11_2', '11_1', '11_0', '10_2']; + +export const passwordPolicyEnforcerDeversionRedirects = OLD_VERSION_URL_SEGMENTS.map((segment) => ({ + from: `/docs/passwordpolicyenforcer/${segment}`, + to: '/docs/passwordpolicyenforcer', +})); 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. From 4c1a81f443e0d3229c5a62d4b972f8004a21da76 Mon Sep 17 00:00:00 2001 From: Dan Piazza <220388267+DanPiazza-Netwrix@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:37:05 -0400 Subject: [PATCH 2/7] Redirect old PPE versioned URLs to their unversioned equivalent page 12.0, 11.2, 11.1, and 11.0 share the same section layout, so old links into them now resolve to the exact same page under the new unversioned root (derived by walking the 12.0 doc tree and checking each file also exists in the older version, rather than hand-listing every page). 10.2's layout was reorganized for 11.x/12.0, so its old pages use an explicit, content-verified old-path -> new-path map instead of a blind path rewrite. Anything with no clear one-to-one match on either version still falls back to the docs root via the existing client-side redirect. --- .../passwordpolicyenforcer-deversion.js | 164 ++++++++++++++++-- 1 file changed, 152 insertions(+), 12 deletions(-) diff --git a/src/config/redirects/passwordpolicyenforcer-deversion.js b/src/config/redirects/passwordpolicyenforcer-deversion.js index 41bae5114a..6e0751cecd 100644 --- a/src/config/redirects/passwordpolicyenforcer-deversion.js +++ b/src/config/redirects/passwordpolicyenforcer-deversion.js @@ -3,16 +3,156 @@ // versions were removed from the build (their source files are untouched on // disk, just unplugged from src/config/products.js). // -// These are static redirects for the well-known version-root URLs so old -// bookmarks/links to those roots get a real (crawlable, no-JS) redirect rather -// than depending on the client-side 404 fallback (findVersionlessRedirect), -// which only fires for a browser that hits a dead route. Deep links into pages -// under those old versions aren't enumerated here — the 10.2 layout in -// particular doesn't map cleanly onto 12.0 — so they fall through to that -// client-side fallback, which sends any old versioned URL to the docs root. -const OLD_VERSION_URL_SEGMENTS = ['12_0', '11_2', '11_1', '11_0', '10_2']; - -export const passwordPolicyEnforcerDeversionRedirects = OLD_VERSION_URL_SEGMENTS.map((segment) => ({ - from: `/docs/passwordpolicyenforcer/${segment}`, - to: '/docs/passwordpolicyenforcer', +// 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. Unmapped 10.2 pages (content +// that was dropped or merged in a way with no single clear target — e.g. the +// old mailer pages, now covered by KB articles) also fall 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', + 'administration/domain_and_local_policies': 'installation/domain_and_local_policies', + 'administration/hibpupdater': 'admin/hibpupdater', + 'administration/installation/disable_windows_rules': 'installation/disable_windows_rules', + 'administration/installation/writeback': 'admin/writeback', + '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', + 'administration/ppe_tool': 'admin/ppe_tool', + 'administration/properties/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', + '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/rules': 'admin/manage-policies/rules/', + 'administration/rules/similarity_rule': 'admin/manage-policies/rules/similarity_rule', + 'administration/rules/unique_characters': 'admin/manage-policies/rules/unique_characters', + 'administration/troubleshooting': 'admin/troubleshooting', + '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, })); + +export const passwordPolicyEnforcerDeversionRedirects = [ + ...exactMatchRedirects, + ...tenTwoRedirects, +]; From 23430a214c88010814c70af102b13f552609cc83 Mon Sep 17 00:00:00 2001 From: Dan Piazza <220388267+DanPiazza-Netwrix@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:57:54 -0400 Subject: [PATCH 3/7] Fix folder-index redirects not matching the no-trailing-slash URL A folder-index route (e.g. admin/cmdlets, whose file shares its parent folder's name) was only registered as a redirect with a trailing slash, so the plain /admin/cmdlets URL a reader actually types missed it and fell through to the generic root fallback instead. --- .../passwordpolicyenforcer-deversion.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/config/redirects/passwordpolicyenforcer-deversion.js b/src/config/redirects/passwordpolicyenforcer-deversion.js index 6e0751cecd..406045f567 100644 --- a/src/config/redirects/passwordpolicyenforcer-deversion.js +++ b/src/config/redirects/passwordpolicyenforcer-deversion.js @@ -152,7 +152,19 @@ const tenTwoRedirects = Object.entries(TEN_TWO_PAGE_MAP).map(([oldRoute, newRout to: newRoute ? `${NEW_PREFIX}/${newRoute}` : NEW_PREFIX, })); -export const passwordPolicyEnforcerDeversionRedirects = [ +// 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, -]; +]); From a1b95130b57975bd41e08582b63511efb53e8a96 Mon Sep 17 00:00:00 2001 From: Dan Piazza <220388267+DanPiazza-Netwrix@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:50:49 -0400 Subject: [PATCH 4/7] Only load Google Analytics in production builds npm run start runs in development mode, where the gtag script often can't load (network, ad blockers), leaving window.gtag undefined and throwing a runtime error overlay on every route change. Production builds are unaffected. --- docusaurus.config.js | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docusaurus.config.js b/docusaurus.config.js index 0076aab720..3d0de36c98 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -197,14 +197,19 @@ 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', From 0f9ac7012be8e912902b23f9c42a382b6c039262 Mon Sep 17 00:00:00 2001 From: Dan Piazza <220388267+DanPiazza-Netwrix@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:48:15 -0400 Subject: [PATCH 5/7] Map most remaining 10.2 pages by content, not just filename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read each previously-unmapped 10.2 page's title/content and matched it to whichever 12.0 page now covers that topic: mailer/license settings consolidated into Settings, uninstall steps into the server installer, Support Tools into System Audit and Support, first/last-character and username-similarity rules into Character rules and Similarity, and password client template/insert/multilingual customization into its configuration page. Only administration/password_reset.md is still left unmapped — it covered integration with the separate Password Reset product and has no PPE-side equivalent in 12.0. --- .../passwordpolicyenforcer-deversion.js | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/config/redirects/passwordpolicyenforcer-deversion.js b/src/config/redirects/passwordpolicyenforcer-deversion.js index 406045f567..12e496c6df 100644 --- a/src/config/redirects/passwordpolicyenforcer-deversion.js +++ b/src/config/redirects/passwordpolicyenforcer-deversion.js @@ -13,9 +13,13 @@ // 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. Unmapped 10.2 pages (content -// that was dropped or merged in a way with no single clear target — e.g. the -// old mailer pages, now covered by KB articles) also fall through to the root. +// 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). The only page left +// unmapped is administration/password_reset.md, which covered integration +// with the separate Netwrix Password Reset product and has no PPE-side +// equivalent in 12.0; it falls through to the root like any other stale link +// with no single clear target. import { readdirSync, existsSync, statSync } from 'fs'; import { join, resolve } from 'path'; @@ -91,10 +95,25 @@ const exactMatchRedirects = STRUCTURALLY_IDENTICAL_VERSIONS.flatMap((version) => 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': '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', + 'administration/mailer/email_message_options': 'admin/settings', + 'administration/mailer/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', @@ -108,6 +127,11 @@ const TEN_TWO_PAGE_MAP = { '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/properties': 'admin/settings', 'administration/rules/character_pattern': 'admin/manage-policies/rules/patterns', @@ -115,6 +139,9 @@ const TEN_TWO_PAGE_MAP = { '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', @@ -126,8 +153,15 @@ const TEN_TWO_PAGE_MAP = { // it as that folder's category index (trailing slash, no repeated segment). 'administration/rules/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', From a7b28cbf16454185d10d6abfe592be06f37a9598 Mon Sep 17 00:00:00 2001 From: Dan Piazza <220388267+DanPiazza-Netwrix@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:00:57 -0400 Subject: [PATCH 6/7] Map the last unmapped 10.2 page (password_reset.md) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was an intro/comparison blurb for the separate Netwrix Password Reset product, not integration documentation — that same PPE Web vs. Password Reset comparison now lives in the Web overview page, so redirect there instead of falling back to the docs root. --- .../redirects/passwordpolicyenforcer-deversion.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/config/redirects/passwordpolicyenforcer-deversion.js b/src/config/redirects/passwordpolicyenforcer-deversion.js index 12e496c6df..6e8d810e72 100644 --- a/src/config/redirects/passwordpolicyenforcer-deversion.js +++ b/src/config/redirects/passwordpolicyenforcer-deversion.js @@ -15,11 +15,9 @@ // 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). The only page left -// unmapped is administration/password_reset.md, which covered integration -// with the separate Netwrix Password Reset product and has no PPE-side -// equivalent in 12.0; it falls through to the root like any other stale link -// with no single clear target. +// 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'; @@ -111,6 +109,9 @@ const TEN_TWO_PAGE_MAP = { // 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/mailer': 'admin/settings', 'administration/properties/license_generator': 'admin/settings', From ffed98cc3169198569f0a6c2270a9adda63dc68a Mon Sep 17 00:00:00 2001 From: Dan Piazza <220388267+DanPiazza-Netwrix@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:35:49 -0400 Subject: [PATCH 7/7] Fix repeated-segment keys in the 10.2 PPE redirect map Docusaurus serves a doc whose filename matches its parent folder at the folder URL, dropping the repeated segment. Four TEN_TWO_PAGE_MAP keys still had the repeated segment, so they never matched the URLs 10.2 actually served (e.g. /administration/rules/rules instead of /administration/rules). Generated with AI Co-Authored-By: Claude Code --- src/config/redirects/passwordpolicyenforcer-deversion.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/redirects/passwordpolicyenforcer-deversion.js b/src/config/redirects/passwordpolicyenforcer-deversion.js index 6e8d810e72..1d46926ffa 100644 --- a/src/config/redirects/passwordpolicyenforcer-deversion.js +++ b/src/config/redirects/passwordpolicyenforcer-deversion.js @@ -101,7 +101,7 @@ const TEN_TWO_PAGE_MAP = { // 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': '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', @@ -113,7 +113,7 @@ const TEN_TWO_PAGE_MAP = { // 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/mailer': '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', @@ -134,7 +134,7 @@ const TEN_TWO_PAGE_MAP = { '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/properties': 'admin/settings', + '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', @@ -152,7 +152,7 @@ const TEN_TWO_PAGE_MAP = { '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/rules': 'admin/manage-policies/rules/', + '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',