Skip to content

Link to API pages with cross-links and publish them in links.json - #4731

Merged
Mpdreamz merged 18 commits into
mainfrom
feature/floating-links
Nov 5, 2026
Merged

Mpdreamz merged 18 commits into
mainfrom
feature/floating-links

Conversation

@Mpdreamz

@Mpdreamz Mpdreamz commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

A docset that mounts an API now publishes its pages in links.json, and a cross-link such as elasticsearch://api/elasticsearch/8/operation/operation-search resolves to the page or fails the build when the page is missing in that version. Docsets that mount no API publish the same links.json as before.

Affects: Links & redirects, Authoring, API reference

Prompt summary: Make API pages part of the navigation tree in isolated and assembler builds, so links to them can be validated like any other cross-link. Keep the published form compact and every lookup O(1), however many API versions a section has. This is the third step of the design in RFC: Floating sections in the navigation tree.

Why

An API page is not a Markdown file, so it is not in links.json. An author cannot cross-link to one, and a hard-coded https://www.elastic.co/docs/api/doc/... link is not checked in an isolated build. One API can have several versions of thousands of pages, and the same page paths repeat in every version.

What

One entry per page, with its versions

RepositoryLinks gains an optional floating property, keyed by {kind}/{key}. For each API it lists every page path once with a bit mask of the versions that contain it. The property is left out when empty, so existing links.json files do not change.

Each version states its root

The section does not name a routing pattern. Each version carries its name, the root of its URLs, and whether it is hidden or current. A reader joins a root and a page path, and never adds a v segment or knows how the API routes. The API Explorer builds the roots with ApiUrlBuilder, the same code that routes the pages.

Aliases under their canonical page

The former per-operation paths of a collapsed operation are listed under the canonical path, per version, so the canonical path is written once however many aliases it has. The reader builds the reverse lookup once, so finding the canonical page of an alias stays one lookup.

The lookup

CrossLinkResolver splits api/{key}[/{version}]/{page}, finds the section by key, maps the version to its bit and tests the mask of the page. The version is the exact name of a version, such as 8 or main. A page of the current version with the same name wins over the version, so adding a version never changes where a link points. Without it, the link reaches the current version, and a section with no current version does not guess one. Every step is a hash lookup or a bit test, so the cost does not grow with the number of versions.

Resolving to a site path

API pages sit at the site root, outside every repository path_prefix. IUriEnvironmentResolver.ResolveSitePath builds the URL from the site root. An assembler build links to the page on the site it builds, and an isolated build links to the published page on https://www.elastic.co/docs/. Codex builds link to the published page too, and a #fragment stays on the URL unchecked.

Emission and docs

IFloatingSection.ToLinkSection builds the section from the same per-version page names the version switcher uses. The syntax is documented in docs/data/openapi/api-explorer.md.

Example

The excerpts come from a real build of docs/ with - api: elasticsearch in the toc, trimmed to a few entries. The aliases entry is made up: that build had no collapsed operations.

A docset that mounts an API gets one extra property in links.json. Everything else in the file is unchanged.

{
  "origin": { "...": "unchanged" },
  "url_path_prefix": "",
  "links": { "index.md": {}, "...": {} },
  "cross_links": [],
  "floating": {
    "api/elasticsearch": {
      "versions": [
        { "name": "8", "root": "api/doc/elasticsearch/v8", "hidden": true },
        { "name": "9", "root": "api/doc/elasticsearch/v9", "hidden": true },
        { "name": "main", "root": "api/doc/elasticsearch", "current": true }
      ],
      "pages": {
        "": 7,
        "authentication": 7,
        "operation/operation-search": 7,
        "operation/operation-cat-circuit-breaker": 6,
        "operation/operation-autoscaling-get-autoscaling-policy": 1
      },
      "aliases": {
        "8": {
          "operation/operation-search": ["operation/operation-search-1", "operation/operation-search-2"]
        }
      }
    }
  }
}

To rebuild a URL, join the root of a version and a page path. The landing page has the empty path, and its URL is the root. The position of a version in versions is its bit, so 8 is bit 0, 9 is bit 1 and main is bit 2. A mask says which versions contain the page:

Page Mask Bits Exists in
operation/operation-search 7 111 8, 9, main
operation/operation-cat-circuit-breaker 6 110 9, main (added in 9.x)
operation/operation-autoscaling-get-autoscaling-policy 1 001 8 only (gone in newer versions)
"" (the landing page) 7 111 8, 9, main

8 and 9 are hidden, so they are reachable by link but out of the sitemap, and main is the visible, current version. In that build, 579 pages exist in every version, 44 only in 9 and main, and 11 only in 8, so 634 entries cover 1,902 page and version combinations. All 1,902 match the files the build rendered, found through the stated roots. A docset that mounts no API has no floating property at all.

Cross-links resolve against that section. These results come from running the real resolver on the real links.json of that build, with the repository name docs-builder:

Cross-link Result
docs-builder://api/elasticsearch/operation/operation-search https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-search
docs-builder://api/elasticsearch/8/operation/operation-search https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-search
docs-builder://api/elasticsearch/main/operation/operation-search https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-search
docs-builder://api/elasticsearch https://www.elastic.co/docs/api/doc/elasticsearch
docs-builder://api/elasticsearch/operation/operation-search#responses https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-search#responses
docs-builder://api/elasticsearch/operation/operation-autoscaling-get-autoscaling-policy error: 'operation/operation-autoscaling-get-autoscaling-policy' is not a page of 'api/elasticsearch' in the current version.
docs-builder://api/elasticsearch/8/operation/operation-cat-circuit-breaker error: 'operation/operation-cat-circuit-breaker' is not a page of 'api/elasticsearch' in version '8'.
docs-builder://api/elasticsearch/v8/operation/operation-search error: 'v8/operation/operation-search' is not a page of 'api/elasticsearch' in the current version. The version is named 8, and no reader guesses a v prefix.

Stack: 3 of 5, on top of #4718.

Out of scope: the assembler wiring, navigation.yml mounting, replacing the link check in #4653, and anchor checking. Only a docset that mounts an API with an api: toc entry publishes anything, and no docset does yet.

Verify

mise run test
dotnet test --project tests/Elastic.Markdown.Tests --treenode-filter "/*/*/FloatingLinkTests/*"
dotnet test --project tests/Elastic.ApiExplorer.Tests --treenode-filter "/*/*/OpenApiGeneratorMultiVersionTests/FloatingSection*"

🤖 Generated with Claude Code

Mpdreamz and others added 10 commits November 5, 2026 12:02
A toc entry such as `api: elasticsearch` now mounts a floating section: a set of pages that owns its URL root and sidebar and is not made of Markdown files. A provider resolves the section before navigation is built, and it renders as an island. `CreateLinkReference()` skips these nodes instead of casting them to `MarkdownFile`. No provider is registered yet, so a build that uses the entry reports an error.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…entries

The site assembler dropped every root child of a mounted docset and warned
that it was undeclared, so an `api:` entry vanished from the assembled
navigation. Floating section roots now carry a marker interface and the
assembler keeps them. An `api:` entry that also sets `children:`, `file:` or
other keys now reports an error instead of silently dropping them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A floating section root now exists as soon as the toc mounts it, with its landing URL and title, so layout code can link to it. The children come later from `ResolveAsync`, which `DocumentationSet.ResolveFloatingSectionsAsync` runs before generation. The children keep their own numbering, so filling them never renumbers the surrounding navigation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The narrative root copied only leaf items into the site tree, so an
`- api: <key>` entry in its toc resolved and then vanished. The root now
keeps floating section roots next to its leaves.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
ResolveDirectoryTree now fills floating sections, so DocumentationGenerator, the assembler and the codex build see their pages without a separate call. The test goes through ResolveDirectoryTree and checks resolution runs once.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An `api: <key>` toc entry now resolves to an API section. Its root exists when the toc is read, and `ResolveAsync` fills it with the version trees before generation. The generator builds each API's trees once and renders from them, so mounting a section adds no second fetch or build. Only the current version is visible.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The docset now fills floating sections itself, so a build run with `--skip-api` resolves an `api:` entry without a generator. The section stays empty and hidden instead of failing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ip-api

A spec that fails to build no longer stops the Markdown build: the section reports the error and hides. The section also hides when no main version resolved, and a hidden API no longer appears in the section dropdown. The generation state records --skip-api, so toggling it triggers a full build. The docs now say that only the isolated build accepts api: entries.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The floating section no longer emits its own error when tree building fails, because the generator awaits the same cached task and already reports it with the stack trace. The no-main test now makes main unreadable without throwing, so it proves released versions loaded. A new build-twice test covers toggling --skip-api over unchanged pages.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A mounted API now publishes its pages in `links.json` under `floating`, one entry per page path with a bit mask of the versions that contain it. A cross-link such as `elasticsearch://api/elasticsearch/v8/operation/operation-search` resolves through hash lookups and a bit test, and a page missing in that version is an error. The resolver builds the final URL from the site root, because API pages sit outside every repository path prefix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@Mpdreamz
Mpdreamz requested a review from a team as a code owner November 5, 2026 12:55
@Mpdreamz
Mpdreamz requested a review from reakaleek November 5, 2026 12:55
An alias now belongs to the versions that list it, so a link to an alias
in a version without it fails, and an alias can name different pages in
different versions. A cross-link to an API page keeps its #fragment.
CodexAwareUriResolver sends API pages to the published site instead of
a repository path.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Mpdreamz and others added 3 commits November 5, 2026 14:05
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A fragment stays on the resolved URL and the build does not check the anchor. Aliases of a collapsed operation resolve only in the versions that list them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A floating link section no longer assumes how a kind routes its URLs. Each version names its root, and whether it is hidden or current, so a reader joins a root and a page path and never adds a `v` segment. A cross-link picks a version by its name, and aliases are listed under their canonical path, which writes that path once however many aliases it has.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An API page called `main` or `8` was read as a version, so the link reached
the landing page. A page of the current version now takes precedence, so
adding a version never changes where an existing link points. The current
version's bit is cached instead of scanned for on every unversioned link.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The lookups of a section are built on first use. The copy that a `with` expression makes carried them over, so replacing the versions or the aliases kept answering from the old data, and a test failed only when another test had used the shared fixture first. A copy now keeps the data and none of the lookups.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The squash merge of the previous PR left two copies of the test, which stops the test project compiling on main.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Docs preview (local build)

Handbook preview: https://docs-v3-preview.elastic.dev/elastic/docs-builder/pull/4731/

Sample changed pages:

@Mpdreamz
Mpdreamz merged commit d62e8fb into main Nov 5, 2026
36 checks passed
@Mpdreamz
Mpdreamz deleted the feature/floating-links branch November 5, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant