diff --git a/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs b/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs index 00426eae1d..3b11bbdd9d 100644 --- a/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs +++ b/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs @@ -94,7 +94,7 @@ private async IAsyncEnumerable ExportFromUrl( _ = response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(ctx); - var settings = new OpenApiReaderSettings { LeaveStreamOpen = false }; + var settings = new OpenApiReaderSettings { LeaveStreamOpen = false, RuleSet = ValidationRuleSet.GetEmptyRuleSet() }; var openApiDocument = await OpenApiDocument.LoadAsync(stream, settings: settings, cancellationToken: ctx); return openApiDocument.Document; diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs index 6f8a7f4fe7..5aad0d56ec 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs @@ -55,7 +55,6 @@ public abstract class ApiViewModel(ApiRenderContext context) public ApiLayoutViewModel CreateGlobalLayoutModel() { - var rootPath = BuildContext.SiteRootPath ?? GetDefaultRootPath(BuildContext.UrlPathPrefix); var docTitle = Document.Info?.Title ?? "API Documentation"; var pageTitle = LayoutPageTitle; var documentTitle = pageTitle is not null @@ -73,7 +72,6 @@ public ApiLayoutViewModel CreateGlobalLayoutModel() Next = null, NavigationHtml = NavigationHtml, UrlPathPrefix = BuildContext.UrlPathPrefix, - Htmx = new DefaultHtmxAttributeProvider(rootPath), AllowIndexing = BuildContext.AllowIndexing, CanonicalBaseUrl = BuildContext.CanonicalBaseUrl, GoogleTagManager = new GoogleTagManagerConfiguration(), @@ -92,10 +90,4 @@ public ApiLayoutViewModel CreateGlobalLayoutModel() GitHubRef = BuildContext.Git.GitHubRef }; } - - private static string GetDefaultRootPath(string? urlPathPrefix) - { - var prefix = urlPathPrefix?.Trim('/') ?? ""; - return string.IsNullOrEmpty(prefix) ? "/" : $"/{prefix}/"; - } } diff --git a/src/Elastic.ApiExplorer/Model/OpenApiReader.cs b/src/Elastic.ApiExplorer/Model/OpenApiReader.cs index f74c222ee8..67ac6f7ba3 100644 --- a/src/Elastic.ApiExplorer/Model/OpenApiReader.cs +++ b/src/Elastic.ApiExplorer/Model/OpenApiReader.cs @@ -18,7 +18,8 @@ public static class OpenApiReader var settings = new OpenApiReaderSettings { - LeaveStreamOpen = false + LeaveStreamOpen = false, + RuleSet = ValidationRuleSet.GetEmptyRuleSet() }; await using var fs = openApiSpecification.OpenRead(); var openApiDocument = await OpenApiDocument.LoadAsync(fs, settings: settings); diff --git a/src/Elastic.Codex/CodexViewModel.cs b/src/Elastic.Codex/CodexViewModel.cs index b4bb47e6bc..e64802dccc 100644 --- a/src/Elastic.Codex/CodexViewModel.cs +++ b/src/Elastic.Codex/CodexViewModel.cs @@ -70,10 +70,8 @@ public string Static(string path) /// /// Creates the global layout model for the page. /// - public GlobalLayoutViewModel CreateGlobalLayoutModel(IReadOnlyList? codexBreadcrumbs = null) - { - var rootPath = BuildContext.SiteRootPath ?? GetDefaultRootPath(BuildContext.UrlPathPrefix); - return new() + public GlobalLayoutViewModel CreateGlobalLayoutModel(IReadOnlyList? codexBreadcrumbs = null) => + new() { DocsBuilderVersion = ShortId.Create(BuildContext.Version), DocSetName = CodexNavigation.NavigationTitle, @@ -83,7 +81,6 @@ public GlobalLayoutViewModel CreateGlobalLayoutModel(IReadOnlyList -
+
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.Codex/Page/Index.cshtml b/src/Elastic.Codex/Page/Index.cshtml index 436e1b0aae..4529b9b85b 100644 --- a/src/Elastic.Codex/Page/Index.cshtml +++ b/src/Elastic.Codex/Page/Index.cshtml @@ -8,14 +8,6 @@ @inherits RazorSlice @implements IUsesLayout @functions { - static string GetRootPath(string? siteRootPath, string? urlPathPrefix) - { - if (!string.IsNullOrEmpty(siteRootPath)) - return siteRootPath; - var prefix = urlPathPrefix?.Trim('/') ?? ""; - return string.IsNullOrEmpty(prefix) ? "/" : $"/{prefix}/"; - } - static bool IsDocsetRootUrl(string? navUrl, string? docsetUrl, string? urlPathPrefix) { if (string.IsNullOrEmpty(docsetUrl)) @@ -60,7 +52,6 @@ StaticFileContentHashProvider = Model.StaticFileContentHashProvider, ReportIssueUrl = Model.ReportIssueUrl, Breadcrumbs = Model.Breadcrumbs, - Htmx = new DefaultHtmxAttributeProvider(GetRootPath(Model.SiteRootPath, Model.UrlPathPrefix)), CurrentVersion = Model.CurrentDocument.YamlFrontMatter?.Layout == MarkdownPageLayout.LandingPage ? Model.VersionsConfig.VersioningSystems[0].Current : Model.CurrentVersion, AllVersionsUrl = Model.AllVersionsUrl, VersioningSystem = Model.VersioningSystem, diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index 5f1ae936e0..20e94dfebf 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,43 @@ 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 + // Speculative prefetches from the preload extension must pass through + // untouched — without this, preloading a non-docs link would trigger the + // full-page-load fallback below on mere mousedown/hover. + if (event.detail.requestConfig.headers['HX-Preloaded'] === 'true') return + // Only boosted link navigation needs scoping; explicit hx-get widgets + // manage their own requests. + if (!event.detail.boosted) 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/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index d4e0b91134..2f3fb6f1c7 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -239,7 +239,9 @@ body { .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: block; - z-index: 9999; + /* Elastic's global nav (elastic-nav.js) also sits at z-index 9999 and comes + later in the DOM, so a tie here would let it paint over the indicator. */ + z-index: 10000; } .progress { diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/assembler.test.ts b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/assembler.test.ts new file mode 100644 index 0000000000..f0ad6e9de7 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/assembler.test.ts @@ -0,0 +1,43 @@ +import { assemblerStrategy } from './assembler' + +// PR previews serve assembler under a prefix like '/elastic/docs-builder/docs/3634' +// instead of '/docs' — the strategy must key off config.rootPath, not a hardcoded '/docs'. +jest.mock('../../../../config', () => ({ + config: { rootPath: '/elastic/docs-builder/docs/3634' }, +})) + +describe('assemblerStrategy with a non-default rootPath', () => { + it('treats rootPath-prefixed paths as docs paths', () => { + expect( + assemblerStrategy.getPathFromUrl( + '/elastic/docs-builder/docs/3634/get-started' + ) + ).toBe('/elastic/docs-builder/docs/3634/get-started') + }) + + it('rejects paths outside rootPath', () => { + expect(assemblerStrategy.getPathFromUrl('/pricing')).toBe(null) + }) + + it('still excludes the /api sub-app under the prefixed root', () => { + expect( + assemblerStrategy.isExternalDocsUrl( + '/elastic/docs-builder/docs/3634/api/elasticsearch' + ) + ).toBe(true) + }) + + it('accepts absolute self-links on non-elastic.co hosts (previews)', () => { + // jsdom origin is http://localhost + expect( + assemblerStrategy.getPathFromUrl( + 'http://localhost/elastic/docs-builder/docs/3634/get-started' + ) + ).toBe('/elastic/docs-builder/docs/3634/get-started') + expect( + assemblerStrategy.getPathFromUrl( + 'https://some-other-host.dev/elastic/docs-builder/docs/3634/get-started' + ) + ).toBe(null) + }) +}) 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..bfc5867357 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 @@ -1,17 +1,27 @@ +import { config } from '../../../../config' import type { HtmxUrlStrategy } from './types' +// rootPath is '/docs' on prod but varies on PR previews (e.g. +// '/elastic/docs-builder/docs/3634'), so it can't be hardcoded. +const root = config.rootPath.replace(/\/$/, '') +const apiRoot = `${root}/api` +const isDocsPath = (path: string) => + path === root || path.startsWith(`${root}/`) + export const assemblerStrategy: HtmxUrlStrategy = { isExternalDocsUrl: (url) => - url.startsWith('/docs/api/') || url === '/docs/api', + url === apiRoot || url.startsWith(`${apiRoot}/`), getPathFromUrl: (url) => { try { - if (url.startsWith('/')) return url + if (url.startsWith('/')) return isDocsPath(url) ? url : null const parsed = new URL(url) - if ( - parsed.hostname.endsWith('elastic.co') && - parsed.pathname.startsWith('/docs') - ) { + // elastic.co covers canonical links on prod; the origin check + // covers absolute self-links on previews and white-label hosts. + const isThisSite = + parsed.hostname.endsWith('elastic.co') || + parsed.origin === window.location.origin + if (isThisSite && isDocsPath(parsed.pathname)) { return parsed.pathname } return null @@ -19,12 +29,4 @@ export const assemblerStrategy: HtmxUrlStrategy = { return null } }, - - getFirstSegment: (path) => path.replace('/docs/', '/').split('/')[1] ?? '', - - isSimpleSwapPath: (path) => { - const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path - if (normalizedPath === '/docs') return true - return path.startsWith('/docs/api/') || path === '/docs/api' - }, } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/codex.ts b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/codex.ts index 0baf0b6164..25932cd9db 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/codex.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/codex.ts @@ -14,18 +14,4 @@ export const codexStrategy: HtmxUrlStrategy = { return null } }, - - getFirstSegment: (path) => { - const rMatch = path.match(/^\/r\/([^/]+)/) - if (rMatch) return rMatch[1] - const gMatch = path.match(/^\/g\/([^/]+)/) - if (gMatch) return gMatch[1] - return path.split('/').filter(Boolean)[0] ?? '' - }, - - isSimpleSwapPath: (path) => { - const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path - if (normalizedPath === '' || normalizedPath === '/') return true - return path.startsWith('/g/') - }, } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/isolated.ts b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/isolated.ts index 71b00426ef..7a68e668a0 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/isolated.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/isolated.ts @@ -14,8 +14,4 @@ export const isolatedStrategy: HtmxUrlStrategy = { return null } }, - - getFirstSegment: (path) => path.replace('/docs/', '/').split('/')[1] ?? '', - - isSimpleSwapPath: () => false, } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/types.ts b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/types.ts index b45f0dece5..fe65168a67 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/types.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/types.ts @@ -5,6 +5,4 @@ export interface HtmxUrlStrategy { isExternalDocsUrl(url: string): boolean getPathFromUrl(url: string): string | null - getFirstSegment(path: string): string - isSimpleSwapPath(path: string): boolean } 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..47ac641d24 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,16 +1,11 @@ -import { - applyHtmxAttributes, - getPathFromUrl, - isExternalDocsUrl, - useCurrentPathname, -} from './utils' +import { getPathFromUrl, isExternalDocsUrl } from './utils' import htmx from 'htmx.org' import { RefObject, useEffect } from 'react' /** - * Hook that processes all links in a container element. - * For internal docs links, applies htmx attributes for SPA navigation. - * For external links (non-elastic.co or /docs/api), adds hx-disable to prevent htmx processing. + * Hook that normalizes all links in a container element: internal docs links + * get path hrefs and inherit hx-boost from ; external links (other + * domains, /docs/api) get hx-disable so htmx leaves them alone. * * @param containerRef - Ref to the container element * @param dependencies - Additional dependencies that should trigger reprocessing (e.g., content) @@ -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..98ad370190 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' @@ -15,13 +10,11 @@ export interface UseHtmxLinkResult { } /** - * Hook that applies htmx attributes to a single anchor element. - * Returns a ref to attach to the anchor element and the normalized href. + * Hook that normalizes a single anchor: internal docs links get a path href + * and inherit hx-boost from ; external links (other domains, /docs/api) + * get hx-disable so htmx leaves them alone. * * Handles both paths (/docs/...) and full URLs (https://elastic.co/docs/...). - * HTMX attributes are not applied for: - * - External non-elastic.co URLs - * - External docs URLs (e.g., /docs/api) which are served from separate sites * * @param url - The path or full URL for the link * @returns Object with ref and normalized href @@ -37,7 +30,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 +46,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..2d67b4fa54 100644 --- a/src/Elastic.Documentation.Site/Htmx.cs +++ b/src/Elastic.Documentation.Site/Htmx.cs @@ -2,67 +2,14 @@ // 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. -public class DefaultHtmxAttributeProvider(string rootPath) : IHtmxAttributeProvider -{ - public const string Preload = "mousedown"; - - public string GetRootPath() => rootPath; - - public virtual string GetHxSelectOob(bool hasSameTopLevelGroup) => - hasSameTopLevelGroup - ? "#content-container,#toc-nav" - : "#content-container,#toc-nav,#nav-tree,#nav-dropdown"; - - 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(); - } - - 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(); - } -} - -/// Static facade for backward compatibility. Prefer injecting IHtmxAttributeProvider. +/// +/// Boosted links use htmx's default whole-body swap with hx-preserve islands, so links no +/// longer need hx-select-oob. preload stays per-link because the preload extension ignores +/// ancestor attributes. +/// public static class Htmx { - private static readonly IHtmxAttributeProvider Default = new DefaultHtmxAttributeProvider("/"); - - public static string GetHxSelectOob(bool hasSameTopLevelGroup) => - Default.GetHxSelectOob(hasSameTopLevelGroup); - - public const string Preload = DefaultHtmxAttributeProvider.Preload; - - public static string GetHxAttributes( - bool hasSameTopLevelGroup = false, - string? preload = Preload, - string? hxSwapOob = null - ) => - Default.GetHxAttributes(hasSameTopLevelGroup, preload, hxSwapOob); - - public static string GetNavHxAttributes(bool hasSameTopLevelGroup = false, string? preload = Preload) => - Default.GetNavHxAttributes(hasSameTopLevelGroup, preload); -} - -/// HTMX provider for codex builds. Includes #codex-breadcrumbs in swap targets so the sub-header updates on navigation. -public class CodexHtmxAttributeProvider(string rootPath) : DefaultHtmxAttributeProvider(rootPath) -{ - public override string GetHxSelectOob(bool hasSameTopLevelGroup) => - $"{base.GetHxSelectOob(hasSameTopLevelGroup)},#codex-breadcrumbs"; + public const string Preload = "mousedown"; } diff --git a/src/Elastic.Documentation.Site/IHtmxAttributeProvider.cs b/src/Elastic.Documentation.Site/IHtmxAttributeProvider.cs deleted file mode 100644 index 9924a221ee..0000000000 --- a/src/Elastic.Documentation.Site/IHtmxAttributeProvider.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// 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 - -namespace Elastic.Documentation.Site; - -/// Provides HTMX attributes for links and layout. Implementations customize swap behavior per build type. -public interface IHtmxAttributeProvider -{ - /// Gets the root path for HTMX navigation (e.g. data-root-path). - string GetRootPath(); - - /// Gets the hx-select-oob value based on whether the target is in the same top-level group. - string GetHxSelectOob(bool hasSameTopLevelGroup); - - /// Gets HTMX attributes for a link. - string GetHxAttributes(bool hasSameTopLevelGroup = false, string? preload = "mousedown", string? hxSwapOob = null); - - /// Gets HTMX attributes for navigation links. - string GetNavHxAttributes(bool hasSameTopLevelGroup = false, string? preload = "mousedown"); -} 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/Navigation/IsolatedBuildNavigationHtmlWriter.cs b/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs index d9dce74edc..5d566c3a62 100644 --- a/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs +++ b/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs @@ -7,7 +7,6 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Extensions; using Elastic.Documentation.Navigation; -using Elastic.Documentation.Site; namespace Elastic.Documentation.Site.Navigation; @@ -56,13 +55,8 @@ private IRootNavigationItem SelectNavigationR return useRequestedRoot ? requestedRoot : siteRoot; } - private NavigationViewModel CreateNavigationModel(IRootNavigationItem navigation) - { - var rootPath = context.SiteRootPath ?? GetDefaultRootPath(context.UrlPathPrefix); - var htmx = context.BuildType == BuildType.Codex - ? new CodexHtmxAttributeProvider(rootPath) - : new DefaultHtmxAttributeProvider(rootPath); - return new() + private NavigationViewModel CreateNavigationModel(IRootNavigationItem navigation) => + new() { Title = navigation.NavigationTitle, TitleUrl = navigation.Url, @@ -71,15 +65,7 @@ private NavigationViewModel CreateNavigationModel(IRootNavigationItem>().ToList(), - Htmx = htmx, BuildType = context.BuildType, Branding = context.Configuration.Branding }; - } - - private static string GetDefaultRootPath(string? urlPathPrefix) - { - var prefix = urlPathPrefix?.Trim('/') ?? ""; - return string.IsNullOrEmpty(prefix) ? "/" : $"/{prefix}/"; - } } diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs b/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs index 85c15e23d5..509618da08 100644 --- a/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs +++ b/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information using Elastic.Documentation.Navigation; -using Elastic.Documentation.Site; namespace Elastic.Documentation.Site.Navigation; @@ -14,6 +13,4 @@ public class NavigationTreeItem public required INodeNavigationItem SubTree { get; init; } public required bool IsPrimaryNavEnabled { get; init; } public required bool IsGlobalAssemblyBuild { get; init; } - public required string RootNavigationId { get; set; } - public required IHtmxAttributeProvider Htmx { get; init; } } diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs b/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs index b241f797fc..a1d87eaa96 100644 --- a/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs +++ b/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs @@ -5,7 +5,6 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Navigation; -using Elastic.Documentation.Site; namespace Elastic.Documentation.Site.Navigation; @@ -21,8 +20,6 @@ public class NavigationViewModel /// Controls whether to split the navigation tree automatically. public required bool IsUsingNavigationDropdown { get; init; } - public required IHtmxAttributeProvider Htmx { get; init; } - public BuildType BuildType { get; init; } = BuildType.Isolated; public BrandingConfiguration? Branding { get; init; } diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml index 60b45821cd..5bf2791dd1 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml @@ -9,7 +9,7 @@
@if (Model.BuildType != BuildType.Codex && Model.Branding is null) { - + } @@ -39,7 +39,7 @@
-
diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml index 868232d732..f59bc3d9f3 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml @@ -80,9 +80,7 @@ IsPrimaryNavEnabled = Model.IsPrimaryNavEnabled, IsGlobalAssemblyBuild = Model.IsGlobalAssemblyBuild, Level = Model.Level + 1, - SubTree = g, - RootNavigationId = Model.RootNavigationId, - Htmx = Model.Htmx + SubTree = g })) } diff --git a/src/Elastic.Documentation.Site/_GlobalLayout.cshtml b/src/Elastic.Documentation.Site/_GlobalLayout.cshtml index d2e300e661..c17a8f663c 100644 --- a/src/Elastic.Documentation.Site/_GlobalLayout.cshtml +++ b/src/Elastic.Documentation.Site/_GlobalLayout.cshtml @@ -1,4 +1,5 @@ @inherits RazorLayoutSlice +@using Elastic.Documentation @@ -12,8 +13,7 @@ class="group/body text-ink has-[#primary-nav-hamburger:checked]:overflow-hidden min-h-screen build-type-@(Model.BuildType.ToString().ToLowerInvariant())@(Model.Features.AirGappedEnabled ? " air-gapped" : "")" hx-ext="preload, head-support" data-docs-builder-version="@Model.DocsBuilderVersion" - data-root-path="@(Model.Htmx.GetRootPath())" - hx-boost="true" hx-swap="none" hx-push-url="true" hx-indicator="#htmx-indicator" + hx-boost="true" hx-push-url="true" hx-indicator="#htmx-indicator" >
-
-
+@* 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) diff --git a/src/Elastic.Documentation.Site/_ViewModels.cs b/src/Elastic.Documentation.Site/_ViewModels.cs index b3c8386335..04593b02ec 100644 --- a/src/Elastic.Documentation.Site/_ViewModels.cs +++ b/src/Elastic.Documentation.Site/_ViewModels.cs @@ -40,7 +40,6 @@ public record GlobalLayoutViewModel public required string NavigationHtml { get; init; } public required string? UrlPathPrefix { get; init; } - public required IHtmxAttributeProvider Htmx { get; init; } public required Uri? CanonicalBaseUrl { get; init; } /// Breadcrumb trail for codex sub-header (Home / Group / Docset). diff --git a/src/Elastic.Documentation.Site/synthetics/journeys/navigation-test.journey.ts b/src/Elastic.Documentation.Site/synthetics/journeys/navigation-test.journey.ts index 72544d6a5b..e62db2d33a 100644 --- a/src/Elastic.Documentation.Site/synthetics/journeys/navigation-test.journey.ts +++ b/src/Elastic.Documentation.Site/synthetics/journeys/navigation-test.journey.ts @@ -10,6 +10,12 @@ function getSchedule(env: string) { return scheduleMapping[env] || 15 } +/** + * Walks the main navigation paths and, along the way, verifies the htmx + * navigation model: boosted links do a whole-body swap with hx-preserve + * islands. A `window` marker set on first load survives an htmx swap but + * not a full page load, so it distinguishes SPA navigation from a reload. + */ journey('navigation test', ({ page, params }) => { monitor.use({ id: `elastic-co-docs-nav-${params.environment}-v2`, @@ -24,6 +30,9 @@ journey('navigation test', ({ page, params }) => { waitUntil: 'domcontentloaded', }) await expect(page).toHaveTitle(/Elastic Docs \| Elastic Docs/) + await page.evaluate(() => { + window['__synthNoReload'] = true + }) }) step('Click on "Elastic Fundamentals"', async () => { @@ -36,9 +45,23 @@ journey('navigation test', ({ page, params }) => { await expect( page.getByRole('heading', { name: 'Elastic fundamentals' }) ).toBeVisible() + // Tag the nav tree so later steps can tell preserved DOM from a swap + await page.evaluate(() => { + const navTree = document.querySelector('[id^="nav-tree"]') + if (navTree) navTree['__synthOriginal'] = true + }) }) step('Click on "deployment options" in nav', async () => { + // Expand a collapsed nav section so we can assert its state survives + const expandedId = await page.evaluate(() => { + const checkbox = document.querySelector( + '[id^="nav-tree"] input[type="checkbox"]:not(:checked)' + ) + if (checkbox) checkbox.checked = true + return checkbox?.id ?? null + }) + await page .getByRole('link', { name: 'Deployment options' }) .first() @@ -50,9 +73,27 @@ journey('navigation test', ({ page, params }) => { await expect( page.getByRole('heading', { name: 'Deployment options' }) ).toBeVisible() + + // Same-group navigation: no reload, nav tree DOM (and state) preserved + const state = await page.evaluate((id) => { + const navTree = document.querySelector('[id^="nav-tree"]') + return { + noReload: window['__synthNoReload'] === true, + navTreePreserved: navTree?.['__synthOriginal'] === true, + checkboxStillChecked: id + ? (document.getElementById(id) as HTMLInputElement)?.checked + : null, + } + }, expandedId) + expect(state.noReload).toBe(true) + expect(state.navTreePreserved).toBe(true) + if (expandedId) expect(state.checkboxStillChecked).toBe(true) }) step('Click on "Elastic Cloud" in markdown content', async () => { + const treeIdBefore = await page.evaluate( + () => document.querySelector('[id^="nav-tree"]')?.id + ) await page .locator('#markdown-content') .getByRole('link', { name: 'Elastic Cloud' }) @@ -62,6 +103,23 @@ journey('navigation test', ({ page, params }) => { `${host}/docs/deploy-manage/deploy/elastic-cloud` ) await expect(page).toHaveTitle(/Elastic Cloud/) + + // Cross-group navigation: still no reload, but the nav tree is replaced + const state = await page.evaluate(() => { + const navTree = document.querySelector('[id^="nav-tree"]') + return { + noReload: window['__synthNoReload'] === true, + treeId: navTree?.id, + treeIsNewNode: navTree?.['__synthOriginal'] === undefined, + treeShowsNewGroup: + navTree?.querySelector('a[href*="/deploy-manage/"]') !== + null, + } + }) + expect(state.noReload).toBe(true) + expect(state.treeId).not.toBe(treeIdBefore) + expect(state.treeIsNewNode).toBe(true) + expect(state.treeShowsNewGroup).toBe(true) }) step('Use dropdown to navigate to reference', async () => { @@ -73,4 +131,40 @@ journey('navigation test', ({ page, params }) => { .click() await expect(page).toHaveURL(`${host}/docs/reference`) }) + + step( + 'Global nav script executed only once across navigations', + async () => { + const navJsFetches = await page.evaluate( + () => + performance + .getEntriesByType('resource') + .filter((e) => e.name.includes('elastic-nav.js')).length + ) + // elastic-nav.js lives in (head-support keeps it) and must not + // re-execute per navigation; 0 covers environments without the global nav + expect(navJsFetches).toBeLessThanOrEqual(1) + } + ) + + step('/docs/api link triggers a full page load, not htmx', async () => { + await page.evaluate(() => { + const a = document.createElement('a') + a.href = '/docs/api/' + a.id = 'synthetic-api-link' + a.textContent = 'api' + document.querySelector('#content-container')?.appendChild(a) + }) + // A full page load is a navigation request; an htmx request would be an + // XHR carrying the ?v= cache-buster. Status doesn't matter (404 locally). + const [request] = await Promise.all([ + page.waitForRequest( + (req) => req.url().startsWith(`${host}/docs/api/`), + { timeout: 30000 } + ), + page.locator('#synthetic-api-link').click(), + ]) + expect(request.isNavigationRequest()).toBe(true) + expect(new URL(request.url()).searchParams.has('v')).toBe(false) + }) }) diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index 5b6335b19b..be506878f7 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -178,7 +178,6 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc NavigationActiveUrl = navActiveUrl, NavigationHtml = navigationHtmlRenderResult.Html, UrlPathPrefix = markdown.UrlPathPrefix, - SiteRootPath = DocumentationSet.Context.SiteRootPath, AppliesTo = markdown.YamlFrontMatter?.AppliesTo, GithubEditUrl = editUrl, MarkdownUrl = current.Url == "/" ? "/index.md" : current.Url.TrimEnd('/') + ".md", diff --git a/src/Elastic.Markdown/Layout/_Breadcrumbs.cshtml b/src/Elastic.Markdown/Layout/_Breadcrumbs.cshtml index d239d1a963..4f57d8a44d 100644 --- a/src/Elastic.Markdown/Layout/_Breadcrumbs.cshtml +++ b/src/Elastic.Markdown/Layout/_Breadcrumbs.cshtml @@ -20,7 +20,7 @@ itemprop="item" href="@item.Url" @(i == 0 ? "hx-disable=true" : "") - @Model.Htmx.GetHxAttributes(Model.CurrentNavigationItem?.NavigationRoot.Id == item.NavigationRoot.Id)> + preload="mousedown"> @StripBadge(item.NavigationTitle) @if (i < crumbs.Count - 1) diff --git a/src/Elastic.Markdown/Layout/_LandingPage.cshtml b/src/Elastic.Markdown/Layout/_LandingPage.cshtml index 7dd11f233f..d10328edd1 100644 --- a/src/Elastic.Markdown/Layout/_LandingPage.cshtml +++ b/src/Elastic.Markdown/Layout/_LandingPage.cshtml @@ -1,5 +1,5 @@ @inherits RazorSlice -
+
diff --git a/src/Elastic.Markdown/Layout/_PrevNextNav.cshtml b/src/Elastic.Markdown/Layout/_PrevNextNav.cshtml index 1f51d69090..709503ae76 100644 --- a/src/Elastic.Markdown/Layout/_PrevNextNav.cshtml +++ b/src/Elastic.Markdown/Layout/_PrevNextNav.cshtml @@ -11,7 +11,7 @@ { @@ -29,7 +29,7 @@ {
diff --git a/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardView.cshtml b/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardView.cshtml index de946af3c0..257c04f7ca 100644 --- a/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardView.cshtml @@ -2,7 +2,6 @@ @using Elastic.Markdown.Myst.Directives.PageCard @inherits RazorSlice diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index e665bcce75..cf3acf5f93 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -9,7 +9,6 @@ using Elastic.Documentation; using Elastic.Documentation.Extensions; using Elastic.Documentation.Links; -using Elastic.Documentation.Site; using Elastic.Markdown.Diagnostics; using Elastic.Markdown.Helpers; using Elastic.Markdown.IO; @@ -57,7 +56,6 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) var context = processor.GetContext(); link.SetData(nameof(context.CurrentUrlPath), context.CurrentUrlPath); - link.SetData(nameof(IHtmxAttributeProvider), context.Htmx); if (IsInCommentBlock(link) || context.SkipValidation) return match; diff --git a/src/Elastic.Markdown/Myst/ParserContext.cs b/src/Elastic.Markdown/Myst/ParserContext.cs index d3a0a694ad..cd122d37c9 100644 --- a/src/Elastic.Markdown/Myst/ParserContext.cs +++ b/src/Elastic.Markdown/Myst/ParserContext.cs @@ -9,7 +9,6 @@ using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Navigation; -using Elastic.Documentation.Site; using Elastic.Markdown.Diagnostics; using Elastic.Markdown.IO; using Elastic.Markdown.Myst.FrontMatter; @@ -107,8 +106,6 @@ public class ParserContext : MarkdownParserContext, IParserResolvers /// public IFileInfo? OriginalSourcePath { get; } - public IHtmxAttributeProvider Htmx { get; } - public ParserContext(ParserState state) { Build = state.Build; @@ -161,16 +158,5 @@ public ParserContext(ParserState state) contextSubs["context.page_title"] = title; ContextSubstitutions = contextSubs; - - var rootPath = Build.SiteRootPath ?? GetDefaultRootPath(Build.UrlPathPrefix); - Htmx = Build.BuildType == BuildType.Codex - ? new CodexHtmxAttributeProvider(rootPath) - : new DefaultHtmxAttributeProvider(rootPath); - } - - private static string GetDefaultRootPath(string? urlPathPrefix) - { - var prefix = urlPathPrefix?.Trim('/') ?? ""; - return string.IsNullOrEmpty(prefix) ? "/" : $"/{prefix}/"; } } diff --git a/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs b/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs index 4b77432aa7..1698404dc8 100644 --- a/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs @@ -2,7 +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 Elastic.Documentation.Navigation; using Elastic.Documentation.Site; using Elastic.Markdown.IO; using Markdig; @@ -41,15 +40,10 @@ protected override void Write(HtmlRenderer renderer, LinkInline link) _ = renderer.Write('"'); _ = renderer.WriteAttributes(link); - if (link.Url?.StartsWith('/') == true || isCrossLink) - { - var currentRootNavigation = link.GetData("NavigationRoot") as INodeNavigationItem; - var targetRootNavigation = link.GetData("TargetNavigationRoot") as INodeNavigationItem; - var hasSameTopLevelGroup = !isCrossLink && (currentRootNavigation?.Id == targetRootNavigation?.Id); - var htmx = GetHtmxProvider(link) ?? new DefaultHtmxAttributeProvider("/"); - _ = renderer.Write($" hx-select-oob=\"{htmx.GetHxSelectOob(hasSameTopLevelGroup)}\""); - _ = renderer.Write($" preload=\"{DefaultHtmxAttributeProvider.Preload}\""); - } + // Internal links rely on body-level hx-boost for navigation (no hx-select-oob); + // preload stays per-link because the preload extension ignores ancestor attributes. + if (url?.StartsWith('/') == true || isCrossLink) + _ = renderer.Write($" preload=\"{Htmx.Preload}\""); if (isHttpLink && !isCrossLink) { _ = renderer.Write(" target=\"_blank\""); @@ -112,9 +106,6 @@ private static void WriteImage(HtmlRenderer renderer, LinkInline link) _ = renderer.Write(" />"); } - private static IHtmxAttributeProvider? GetHtmxProvider(LinkInline link) => - link.GetData(nameof(IHtmxAttributeProvider)) as IHtmxAttributeProvider; - private static bool IsNestedInsideLink(LinkInline link) { var parent = link.Parent; diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml index 9ecdfe2f2c..7eb53507e3 100644 --- a/src/Elastic.Markdown/Page/Index.cshtml +++ b/src/Elastic.Markdown/Page/Index.cshtml @@ -10,14 +10,6 @@ @inherits RazorSlice @implements IUsesLayout @functions { - static string GetRootPath(string? siteRootPath, string? urlPathPrefix) - { - if (!string.IsNullOrEmpty(siteRootPath)) - return siteRootPath; - var prefix = urlPathPrefix?.Trim('/') ?? ""; - return string.IsNullOrEmpty(prefix) ? "/" : $"/{prefix}/"; - } - public MarkdownLayoutViewModel LayoutModel => new() { DocsBuilderVersion = ShortId.Create(BuildContext.Version), @@ -49,7 +41,6 @@ ReportIssueUrl = Model.ReportIssueUrl, Breadcrumbs = Model.Breadcrumbs, NavigationActiveUrl = Model.NavigationActiveUrl, - Htmx = new DefaultHtmxAttributeProvider(GetRootPath(Model.SiteRootPath, Model.UrlPathPrefix)), CurrentVersion = Model.CurrentDocument.YamlFrontMatter?.Layout == MarkdownPageLayout.LandingPage ? Model.VersionsConfig.VersioningSystems[0].Current : Model.CurrentVersion, AllVersionsUrl = Model.AllVersionsUrl, VersioningSystem = Model.VersioningSystem, diff --git a/src/Elastic.Markdown/Page/IndexViewModel.cs b/src/Elastic.Markdown/Page/IndexViewModel.cs index 31b1dcec69..e2644f6188 100644 --- a/src/Elastic.Markdown/Page/IndexViewModel.cs +++ b/src/Elastic.Markdown/Page/IndexViewModel.cs @@ -86,9 +86,6 @@ public class IndexViewModel /// Codex sub-header breadcrumb trail (Home / Group / Docset). public IReadOnlyList? CodexBreadcrumbs { get; set; } - /// Pre-computed site root path for HTMX. When set (codex builds), used as data-root-path. - public string? SiteRootPath { get; set; } - /// When set, the page performs a client-side redirect to this URL (used for alias pages). public string? RedirectUrl { get; init; } diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs index a833213cc3..2753e570d5 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs @@ -76,7 +76,6 @@ private NavigationViewModel CreateNavigationModel(INodeNavigationItemTests that assembler builds produce correct HTMX attributes on markdown cross-links (same-site, not target=_blank). +/// +/// Navigation relies on body-level hx-boost with hx-preserve islands, so markdown links must +/// not carry per-link htmx attributes. Cross-links stay same-site (no target=_blank). +/// public class AssemblerHtmxMarkdownLinkTests(ITestOutputHelper output) : LinkTestBase(output, "Go to [test](kibana://index.md)") { protected override BuildContext CreateBuildContext( @@ -28,12 +31,11 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void CrossLink_UsesGranularSwap_ForAssembler() => - Html.Should().Contain("hx-select-oob=\"#content-container,#toc-nav,#nav-tree,#nav-dropdown\""); - - [Fact] - public void CrossLink_HasPreload() => + public void CrossLink_HasNoSelectOobButKeepsPreload() + { + Html.Should().NotContain("hx-select-oob"); Html.Should().Contain("preload=\"mousedown\""); + } [Fact] public void CrossLink_NoTargetBlank() => @@ -50,7 +52,7 @@ public void EmitsCrossLink() public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -/// Internal links in assembler use #content-container,#toc-nav (same as isolated). +/// Internal links in assembler carry no per-link htmx attributes. public class AssemblerHtmxInternalLinkTests(ITestOutputHelper output) : LinkTestBase(output, "[Requirements](testing/req.md)") { protected override BuildContext CreateBuildContext( @@ -64,11 +66,8 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void InternalLink_UsesContentContainerAndTocNav_ForAssembler() - { - // Assembler: same-docset links use #content-container,#toc-nav (same as isolated) - Html.Should().Contain("hx-select-oob=\"#content-container,#toc-nav\""); - } + public void InternalLink_HasNoPerLinkHtmxAttributes() => + Html.Should().NotContain("hx-select-oob"); [Fact] public void EmitsNoCrossLink() => Collector.CrossLinks.Should().HaveCount(0); @@ -77,7 +76,7 @@ public void InternalLink_UsesContentContainerAndTocNav_ForAssembler() public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -/// Absolute path links in assembler get HTMX attributes (granular swap when nav roots not same). +/// Absolute path links in assembler carry no per-link htmx attributes. public class AssemblerHtmxAbsolutePathLinkTests(ITestOutputHelper output) : LinkTestBase(output, """ [Elasticsearch](/_static/img/observability.png) @@ -95,10 +94,9 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void AbsolutePathLink_GetsHtmxAttributes_ForAssembler() + public void AbsolutePathLink_HasNoSelectOobButKeepsPreload() { - // Assembler: absolute path links get HTMX (granular swap when hasSameTopLevelGroup is false) - Html.Should().Contain("hx-select-oob=\"#content-container,#toc-nav,#nav-tree,#nav-dropdown\""); + Html.Should().NotContain("hx-select-oob"); Html.Should().Contain("preload=\"mousedown\""); } @@ -106,7 +104,7 @@ public void AbsolutePathLink_GetsHtmxAttributes_ForAssembler() public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -/// Reference-style internal links in assembler use #content-container,#toc-nav. +/// Reference-style internal links in assembler carry no per-link htmx attributes. public class AssemblerHtmxReferenceLinkTests(ITestOutputHelper output) : LinkTestBase(output, """ [test][test] @@ -126,10 +124,8 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void ReferenceLink_UsesContentContainerAndTocNav_ForAssembler() - { - Html.Should().Contain("hx-select-oob=\"#content-container,#toc-nav\""); - } + public void ReferenceLink_HasNoPerLinkHtmxAttributes() => + Html.Should().NotContain("hx-select-oob"); [Fact] public void EmitsNoCrossLink() => Collector.CrossLinks.Should().HaveCount(0); @@ -138,7 +134,7 @@ public void ReferenceLink_UsesContentContainerAndTocNav_ForAssembler() public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -/// Empty-text cross-links in assembler still get granular swap (and emit error). +/// Empty-text cross-links in assembler carry no per-link htmx attributes (and emit error). public class AssemblerHtmxEmptyTextCrossLinkTests(ITestOutputHelper output) : LinkTestBase(output, """ @@ -157,8 +153,8 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void EmptyTextCrossLink_UsesGranularSwap_ForAssembler() => - Html.Should().Contain("hx-select-oob=\"#content-container,#toc-nav,#nav-tree,#nav-dropdown\""); + public void EmptyTextCrossLink_HasNoPerLinkHtmxAttributes() => + Html.Should().NotContain("hx-select-oob"); [Fact] public void EmptyTextCrossLink_NoTargetBlank() => @@ -178,7 +174,7 @@ public void EmitsCrossLink() } } -/// Insert-page-title links (empty text, internal target) use #content-container,#toc-nav. +/// Insert-page-title links (empty text, internal target) carry no per-link htmx attributes. public class AssemblerHtmxInsertPageTitleTests(ITestOutputHelper output) : LinkTestBase(output, """ [](testing/req.md) @@ -196,10 +192,8 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void InsertPageTitle_UsesContentContainerAndTocNav_ForAssembler() - { - Html.Should().Contain("hx-select-oob=\"#content-container,#toc-nav\""); - } + public void InsertPageTitle_HasNoPerLinkHtmxAttributes() => + Html.Should().NotContain("hx-select-oob"); [Fact] public void EmitsNoCrossLink() => Collector.CrossLinks.Should().HaveCount(0); @@ -208,7 +202,7 @@ public void InsertPageTitle_UsesContentContainerAndTocNav_ForAssembler() public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -/// HTTP links in assembler do NOT get HTMX attributes (target="_blank" instead). +/// HTTP links in assembler get target="_blank" and no htmx attributes. public class AssemblerHtmxExternalLinkTests(ITestOutputHelper output) : LinkTestBase(output, """ [link to app]({{some-url-with-a-version}}) diff --git a/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs b/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs index 36554b11a5..36b838c9de 100644 --- a/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs @@ -13,7 +13,7 @@ namespace Elastic.Markdown.Tests.Codex; -/// Codex cross-links resolve to path-only URLs with htmx attributes including codex-breadcrumbs. +/// Codex cross-links resolve to path-only URLs; navigation relies on body-level hx-boost, so links carry no per-link htmx attributes. public class CodexHtmxCrossLinkTests(ITestOutputHelper output) : LinkTestBase(output, "Go to [test](kibana://index.md)") { protected override BuildContext CreateBuildContext( @@ -37,16 +37,11 @@ public void CrossLink_ProducesPathOnlyHref() } [Fact] - public void CrossLink_HasHtmxWithCodexBreadcrumbs() => - Html.Should().Contain("#codex-breadcrumbs"); - - [Fact] - public void CrossLink_HasHtmxSelectOob() => - Html.Should().Contain("hx-select-oob="); - - [Fact] - public void CrossLink_HasPreload() => + public void CrossLink_HasNoSelectOobButKeepsPreload() + { + Html.Should().NotContain("hx-select-oob"); Html.Should().Contain("preload=\"mousedown\""); + } [Fact] public void CrossLink_NoTargetBlank() =>