From aeaf789137945ab58654cff8dea9a977f41f1419 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 9 Jul 2026 21:42:33 +0200 Subject: [PATCH 01/10] Htmx: replace hx-select-oob machinery with body swap + hx-preserve Boosted links now use htmx's default whole-body swap; stable islands (nav tree, headers, search, ask-ai) are marked hx-preserve. The same-top-level-group nav-tree condition becomes structural: group-scoped ids (nav-tree-) with hx-preserve id-matching preserve the tree within a group and swap it across groups. Boost is scoped to /docs/* elastic.co links (excluding /docs/api) via a beforeRequest guard, and elastic-nav.js moves to so head-support keeps it from re-executing per navigation. Synthetics journey extended to assert the SPA behavior. Co-Authored-By: Claude Fable 5 --- src/Elastic.Codex/Landing/LandingView.cshtml | 3 +- src/Elastic.Codex/Landing/_CodexCard.cshtml | 1 - src/Elastic.Codex/Layout/_CodexHeader.cshtml | 4 +- .../Layout/_CodexSubHeader.cshtml | 3 - src/Elastic.Documentation.Site/Assets/main.ts | 44 ++++----- .../shared/htmx/strategies/assembler.ts | 6 +- .../shared/htmx/useHtmxContainer.ts | 14 +-- .../shared/htmx/useHtmxLink.test.tsx | 10 +- .../web-components/shared/htmx/useHtmxLink.ts | 13 +-- .../web-components/shared/htmx/utils.ts | 66 ------------- src/Elastic.Documentation.Site/Htmx.cs | 22 ++--- .../Layout/_AssemblerHeader.cshtml | 13 +-- .../Layout/_Head.cshtml | 6 ++ .../Layout/_IsolatedHeader.cshtml | 2 +- .../Layout/_SecondaryNav.cshtml | 4 +- .../Navigation/_TocTree.cshtml | 7 +- .../_GlobalLayout.cshtml | 2 +- .../journeys/navigation-test.journey.ts | 94 +++++++++++++++++++ .../Layout/_LandingPage.cshtml | 2 +- .../Directives/PageCard/PageCardView.cshtml | 1 - .../Myst/Renderers/HtmxLinkInlineRenderer.cs | 13 +-- .../AssemblerHtmxMarkdownLinkTests.cs | 54 +++++------ .../Codex/CodexHtmxCrossLinkTests.cs | 15 +-- 23 files changed, 196 insertions(+), 203 deletions(-) diff --git a/src/Elastic.Codex/Landing/LandingView.cshtml b/src/Elastic.Codex/Landing/LandingView.cshtml index e84d723dec..713a215928 100644 --- a/src/Elastic.Codex/Landing/LandingView.cshtml +++ b/src/Elastic.Codex/Landing/LandingView.cshtml @@ -20,7 +20,7 @@ style="background-image: linear-gradient(to right, #e7e5e4 1px, transparent 1px), linear-gradient(to bottom, #e7e5e4 1px, transparent 1px); background-size: 20px 20px; background-position: 0 0, 0 0; mask-image: repeating-linear-gradient(to right, black 0px, black 3px, transparent 3px, transparent 8px), repeating-linear-gradient(to bottom, black 0px, black 3px, transparent 3px, transparent 8px), radial-gradient(ellipse 70% 60% at 50% 0%, #000 60%, transparent 100%); -webkit-mask-image: repeating-linear-gradient(to right, black 0px, black 3px, transparent 3px, transparent 8px), repeating-linear-gradient(to bottom, black 0px, black 3px, transparent 3px, transparent 8px), radial-gradient(ellipse 70% 60% at 50% 0%, #000 60%, transparent 100%); mask-composite: intersect; -webkit-mask-composite: source-in;"> -
+
Elastic @@ -141,7 +141,6 @@ var innerCols = repoCount >= 5 || repoCount == 3 ? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3" : repoCount >= 2 ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1";
diff --git a/src/Elastic.Codex/Landing/_CodexCard.cshtml b/src/Elastic.Codex/Landing/_CodexCard.cshtml index 0ec6bbeb04..1758a4d507 100644 --- a/src/Elastic.Codex/Landing/_CodexCard.cshtml +++ b/src/Elastic.Codex/Landing/_CodexCard.cshtml @@ -9,7 +9,6 @@ } } diff --git a/src/Elastic.Codex/Layout/_CodexHeader.cshtml b/src/Elastic.Codex/Layout/_CodexHeader.cshtml index 53387ac69e..948e9132bd 100644 --- a/src/Elastic.Codex/Layout/_CodexHeader.cshtml +++ b/src/Elastic.Codex/Layout/_CodexHeader.cshtml @@ -3,13 +3,13 @@
- + @(new HtmlString(EuiSvgIcons.GetIcon("logo_elastic", "size-6") ?? ""))
- +
diff --git a/src/Elastic.Codex/Layout/_CodexSubHeader.cshtml b/src/Elastic.Codex/Layout/_CodexSubHeader.cshtml index 4214869e87..16b7229275 100644 --- a/src/Elastic.Codex/Layout/_CodexSubHeader.cshtml +++ b/src/Elastic.Codex/Layout/_CodexSubHeader.cshtml @@ -20,7 +20,6 @@ @if (!string.IsNullOrEmpty(crumb.Url)) { @(crumb.Title == "Home" ? new HtmlString(EuiSvgIcons.GetIcon("home", "size-4 fill-current") ?? "") : (object)crumb.Title) @@ -38,7 +37,6 @@ @if (!string.IsNullOrEmpty(home.Url)) { @(new HtmlString(EuiSvgIcons.GetIcon("grid", "size-4 fill-current") ?? "")) @@ -47,7 +45,6 @@ @if (!string.IsNullOrEmpty(header.Url)) { @header.Title } diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index 5f1ae936e0..d5000db7d0 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -23,6 +23,10 @@ import { ATTR_URL_FULL, } from './telemetry/semconv' import { initTocNav } from './toc-nav' +import { + getPathFromUrl, + isExternalDocsUrl, +} from './web-components/shared/htmx/utils' import 'htmx-ext-head-support' import 'htmx-ext-preload' import * as katex from 'katex' @@ -254,38 +258,36 @@ document.addEventListener( ) document.addEventListener('htmx:beforeRequest', function (event: HtmxEvent) { - if ( - event.detail.requestConfig.verb === 'get' && - event.detail.requestConfig.triggeringEvent - ) { + if (event.detail.requestConfig.verb !== 'get') return + const path: string = event.detail.requestConfig.path + if (event.detail.requestConfig.triggeringEvent) { const { ctrlKey, metaKey, shiftKey }: PointerEvent = event.detail.requestConfig.triggeringEvent const { name: os } = getOS() const modifierKey: boolean = os === 'macOS' ? metaKey : ctrlKey if (shiftKey || modifierKey) { event.preventDefault() - window.open( - event.detail.requestConfig.path, - '_blank', - 'noopener,noreferrer' - ) + window.open(path, '_blank', 'noopener,noreferrer') + return } } + // hx-boost intercepts every same-origin link, but only internal docs URLs should + // navigate through htmx (for assembler that means /docs/*; isolated and codex own + // their whole origin). Anything else — marketing pages on the same domain, the + // separate /docs/api app — gets a normal full page load. + const docsPath = getPathFromUrl(new URL(path, location.href).pathname) + if (!docsPath || isExternalDocsUrl(docsPath)) { + event.preventDefault() + window.location.assign(path) + } }) -document.body.addEventListener( - 'htmx:oobBeforeSwap', - function (event: HtmxEvent) { - // Scroll to the top of the page when the content is swapped - if ( - event.target?.id === 'main-container' || - event.target?.id === 'markdown-content' || - event.target?.id === 'content-container' - ) { - window.scrollTo(0, 0) - } +// Boosted navigations swap the whole ; scroll to top like a normal page load +document.body.addEventListener('htmx:afterSwap', function (event: HtmxEvent) { + if (event.target === document.body) { + window.scrollTo(0, 0) } -) +}) document.body.addEventListener( 'htmx:responseError', diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/assembler.ts b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/assembler.ts index 0abc4b4cfa..a21a95ba6e 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/assembler.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/assembler.ts @@ -6,11 +6,13 @@ export const assemblerStrategy: HtmxUrlStrategy = { getPathFromUrl: (url) => { try { - if (url.startsWith('/')) return url + const isDocsPath = (path: string) => + path === '/docs' || path.startsWith('/docs/') + if (url.startsWith('/')) return isDocsPath(url) ? url : null const parsed = new URL(url) if ( parsed.hostname.endsWith('elastic.co') && - parsed.pathname.startsWith('/docs') + isDocsPath(parsed.pathname) ) { return parsed.pathname } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxContainer.ts b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxContainer.ts index 865edefaaf..b5113b40ad 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxContainer.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxContainer.ts @@ -1,9 +1,4 @@ -import { - applyHtmxAttributes, - getPathFromUrl, - isExternalDocsUrl, - useCurrentPathname, -} from './utils' +import { getPathFromUrl, isExternalDocsUrl } from './utils' import htmx from 'htmx.org' import { RefObject, useEffect } from 'react' @@ -24,8 +19,6 @@ export const useHtmxContainer = ( containerRef: RefObject, dependencies: unknown[] = [] ): void => { - const currentPathname = useCurrentPathname() - useEffect(() => { if (!containerRef.current) return @@ -50,9 +43,8 @@ export const useHtmxContainer = ( return } - // Internal docs links - apply htmx attributes + // Internal docs links inherit hx-boost from anchor.setAttribute('href', path) - applyHtmxAttributes(anchor, path, currentPathname) hasProcessedLinks = true }) @@ -60,5 +52,5 @@ export const useHtmxContainer = ( if (hasProcessedLinks) { htmx.process(containerRef.current) } - }, [currentPathname, ...dependencies]) + }, [...dependencies]) } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxLink.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxLink.test.tsx index d9f1a96356..4866b1e0f4 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxLink.test.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxLink.test.tsx @@ -61,12 +61,18 @@ describe('isExternalDocsUrl', () => { describe('getPathFromUrl', () => { describe('paths (starting with /)', () => { - it('should return paths as-is', () => { + it('should return docs paths as-is', () => { expect(getPathFromUrl('/docs/elasticsearch')).toBe( '/docs/elasticsearch' ) expect(getPathFromUrl('/docs/api/kibana')).toBe('/docs/api/kibana') - expect(getPathFromUrl('/')).toBe('/') + expect(getPathFromUrl('/docs')).toBe('/docs') + }) + + it('should return null for same-origin non-docs paths', () => { + expect(getPathFromUrl('/')).toBe(null) + expect(getPathFromUrl('/pricing')).toBe(null) + expect(getPathFromUrl('/docsomething')).toBe(null) }) }) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxLink.ts b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxLink.ts index 6587d49889..5bde0d6629 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxLink.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxLink.ts @@ -1,9 +1,4 @@ -import { - applyHtmxAttributes, - getPathFromUrl, - isExternalDocsUrl, - useCurrentPathname, -} from './utils' +import { getPathFromUrl, isExternalDocsUrl } from './utils' import htmx from 'htmx.org' import { RefObject, useEffect, useMemo, useRef } from 'react' @@ -37,7 +32,6 @@ export interface UseHtmxLinkResult { */ export const useHtmxLink = (url: string): UseHtmxLinkResult => { const anchorRef = useRef(null) - const currentPathname = useCurrentPathname() // Normalize URL to path, returns null for external non-elastic.co URLs const path = useMemo(() => getPathFromUrl(url), [url]) @@ -54,11 +48,10 @@ export const useHtmxLink = (url: string): UseHtmxLinkResult => { // Explicitly disable HTMX for external links anchorRef.current.setAttribute('hx-disable', 'true') } else { - // Apply HTMX attributes for internal docs links - applyHtmxAttributes(anchorRef.current, path, currentPathname) + // Internal docs links inherit hx-boost from ; processing wires them up htmx.process(anchorRef.current) } - }, [path, currentPathname]) + }, [path]) return { ref: anchorRef, href } } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/utils.ts b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/utils.ts index 6caba2af75..97c18ba7fc 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/utils.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/utils.ts @@ -1,73 +1,7 @@ -import { config } from '../../../config' import { urlStrategy } from './urlStrategy' -import { useEffect, useState } from 'react' export const isExternalDocsUrl = (url: string): boolean => urlStrategy.isExternalDocsUrl(url) export const getPathFromUrl = (url: string): string | null => urlStrategy.getPathFromUrl(url) - -/** - * Returns the appropriate hx-select-oob value based on whether - * the target URL is in the same top-level group as the current URL. - * Includes #codex-breadcrumbs for codex builds so the sub-header updates on navigation. - */ -const getHxSelectOob = (targetUrl: string, currentPathname: string): string => { - const currentSegment = urlStrategy.getFirstSegment(currentPathname) - const targetSegment = urlStrategy.getFirstSegment(targetUrl) - const base = - currentSegment === targetSegment - ? '#content-container,#toc-nav' - : '#content-container,#toc-nav,#nav-tree,#nav-dropdown' - return config.buildType === 'codex' ? `${base},#codex-breadcrumbs` : base -} - -/** - * Applies the appropriate htmx attributes to an anchor element. - * Uses different oob swap strategies based on the paths: - * - * - For simple swap paths (landing, /docs/api/*, /g/*): swap only #main-container - * - For same top-level group: swap #content-container,#toc-nav - * - For different top-level group: swap #content-container,#toc-nav,#nav-tree,#nav-dropdown - */ -export const applyHtmxAttributes = ( - anchor: HTMLAnchorElement, - path: string, - currentPathname: string -): void => { - const hxSelectOob = - urlStrategy.isSimpleSwapPath(path) || - urlStrategy.isSimpleSwapPath(currentPathname) - ? '#main-container' - : getHxSelectOob(path, currentPathname) - - anchor.setAttribute('hx-select-oob', hxSelectOob) - anchor.setAttribute('hx-swap', 'none') -} - -/** - * Hook that tracks the current pathname and updates when htmx navigation occurs. - */ -export const useCurrentPathname = (): string => { - const [pathname, setPathname] = useState(window.location.pathname) - - useEffect(() => { - const handleNavigation = () => { - setPathname(window.location.pathname) - } - - document.addEventListener('htmx:pushedIntoHistory', handleNavigation) - window.addEventListener('popstate', handleNavigation) - - return () => { - document.removeEventListener( - 'htmx:pushedIntoHistory', - handleNavigation - ) - window.removeEventListener('popstate', handleNavigation) - } - }, []) - - return pathname -} diff --git a/src/Elastic.Documentation.Site/Htmx.cs b/src/Elastic.Documentation.Site/Htmx.cs index b4fe31adf2..431fb38f4c 100644 --- a/src/Elastic.Documentation.Site/Htmx.cs +++ b/src/Elastic.Documentation.Site/Htmx.cs @@ -2,8 +2,6 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using System.Text; - namespace Elastic.Documentation.Site; /// Default HTMX provider for isolated and assembler builds. @@ -18,25 +16,17 @@ public virtual string GetHxSelectOob(bool hasSameTopLevelGroup) => ? "#content-container,#toc-nav" : "#content-container,#toc-nav,#nav-tree,#nav-dropdown"; + // PoC: boosted links now use htmx's default whole-body swap with hx-preserve islands, + // so links no longer carry hx-select-oob. The preload extension still needs the + // attribute on each link itself (it ignores ancestors). Call sites left intact; + // delete this provider plumbing entirely if the PoC lands. public string GetHxAttributes( bool hasSameTopLevelGroup = false, string? preload = Preload, string? hxSwapOob = null - ) - { - var attributes = new StringBuilder(); - _ = attributes.Append($" hx-select-oob={hxSwapOob ?? GetHxSelectOob(hasSameTopLevelGroup)}"); - _ = attributes.Append($" preload={preload}"); - return attributes.ToString(); - } + ) => $" preload={preload}"; - public string GetNavHxAttributes(bool hasSameTopLevelGroup = false, string? preload = Preload) - { - var attributes = new StringBuilder(); - _ = attributes.Append($" hx-select-oob={GetHxSelectOob(hasSameTopLevelGroup)}"); - _ = attributes.Append($" preload={preload}"); - return attributes.ToString(); - } + public string GetNavHxAttributes(bool hasSameTopLevelGroup = false, string? preload = Preload) => $" preload={preload}"; } /// Static facade for backward compatibility. Prefer injecting IHtmxAttributeProvider. diff --git a/src/Elastic.Documentation.Site/Layout/_AssemblerHeader.cshtml b/src/Elastic.Documentation.Site/Layout/_AssemblerHeader.cshtml index 513d4015c8..c91b963647 100644 --- a/src/Elastic.Documentation.Site/Layout/_AssemblerHeader.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_AssemblerHeader.cshtml @@ -1,21 +1,18 @@ @inherits RazorSlice @if (Model.Features.AirGappedEnabled) { -
+
} else { -
+ @* Preserved: elastic-nav.js (loaded from _Head, where head-support keeps it from + re-executing on navigation) fills #elastic-nav once; hx-preserve keeps the filled + element across body swaps. *@ +
- @{ - var elasticNavUrl = Model.Features.StagingElasticNavEnabled - ? "https://stag-www.elastic.co/elastic-nav.js" - : "https://www.elastic.co/elastic-nav.js"; - } -
@await RenderPartialAsync(_SecondaryNav.Create(Model)) } diff --git a/src/Elastic.Documentation.Site/Layout/_Head.cshtml b/src/Elastic.Documentation.Site/Layout/_Head.cshtml index fa189465d8..dad49fa83f 100644 --- a/src/Elastic.Documentation.Site/Layout/_Head.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_Head.cshtml @@ -27,6 +27,12 @@ } + @if (Model.BuildType == BuildType.Assembler && !Model.Features.AirGappedEnabled) + { + @* In so head-support's merge keeps the identical tag across htmx navigations + instead of re-executing it in the swapped body. Fills #elastic-nav (hx-preserve). *@ + + } @if (Model.Features.WebsiteSearchEnabled && Model.Features.WebsiteSearchScriptUrl is { } websiteSearchUrl) { diff --git a/src/Elastic.Documentation.Site/Layout/_IsolatedHeader.cshtml b/src/Elastic.Documentation.Site/Layout/_IsolatedHeader.cshtml index e4b488c17a..c87249119b 100644 --- a/src/Elastic.Documentation.Site/Layout/_IsolatedHeader.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_IsolatedHeader.cshtml @@ -1,5 +1,5 @@ @inherits RazorSlice -
+
- -
diff --git a/src/Elastic.Documentation.Site/_GlobalLayout.cshtml b/src/Elastic.Documentation.Site/_GlobalLayout.cshtml index af28cba802..435f2176ef 100644 --- a/src/Elastic.Documentation.Site/_GlobalLayout.cshtml +++ b/src/Elastic.Documentation.Site/_GlobalLayout.cshtml @@ -1,4 +1,5 @@ @inherits RazorLayoutSlice +@using Elastic.Documentation @@ -27,12 +28,17 @@ height="0" width="0" style="display:none;visibility:hidden"> } -
-
-
+@* Assembler (non-air-gapped) renders #htmx-indicator anchored to the secondary nav + in _SecondaryNav.cshtml instead of this viewport-fixed variant. *@ +@if (Model.BuildType != BuildType.Assembler || Model.Features.AirGappedEnabled) +{ +
+
+
+
+
Loading
-
Loading
-
+} @(await RenderBodyAsync()) @await RenderSectionAsync(GlobalSections.Footer) From b39c75a921a8c22f43efff2174740cb6d01f669b Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Mon, 13 Jul 2026 17:00:58 +0200 Subject: [PATCH 10/10] Htmx: preserve loading indicator across body swaps Without hx-preserve the swap replaces the indicator mid-animation and settle briefly re-shows the fresh copy restarted from zero, which reads as a second load flash right as the page lands. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml | 6 ++++-- src/Elastic.Documentation.Site/_GlobalLayout.cshtml | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml index ff3e0ff8a7..b7309fc601 100644 --- a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml @@ -41,8 +41,10 @@ @* Anchored below the nav (top-full) so it tracks it in both scroll states — a fixed/--offset-top indicator can't account for the variable-height - elastic-nav global header above. *@ -
+ elastic-nav global header above. hx-preserve keeps the element (and its + running animation) across body swaps; otherwise settle briefly re-shows + the swapped-in copy with the animation restarted from zero. *@ +
diff --git a/src/Elastic.Documentation.Site/_GlobalLayout.cshtml b/src/Elastic.Documentation.Site/_GlobalLayout.cshtml index 435f2176ef..c17a8f663c 100644 --- a/src/Elastic.Documentation.Site/_GlobalLayout.cshtml +++ b/src/Elastic.Documentation.Site/_GlobalLayout.cshtml @@ -32,7 +32,7 @@ in _SecondaryNav.cshtml instead of this viewport-fixed variant. *@ @if (Model.BuildType != BuildType.Assembler || Model.Features.AirGappedEnabled) { -
+